From 25a2c6b7cd37b6cc4d9ee171db751cb1582fa03f Mon Sep 17 00:00:00 2001 From: Tal Derei <70081547+TalDerei@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:50:17 -0800 Subject: [PATCH] view server: latestSwaps (#2005) * stub view server method in prax * changeset * flush out latest swaps view service method * feat(services): implement latestSwaps --------- Co-authored-by: Max Korsunov --- .changeset/dull-spiders-help.md | 5 + packages/protobuf/package.json | 2 +- .../services/src/view-service/assets.test.ts | 19 +- packages/services/src/view-service/index.ts | 2 + .../src/view-service/latest-swaps.test.ts | 344 ++++++++++++++++++ .../services/src/view-service/latest-swaps.ts | 61 ++++ .../services/src/view-service/util/data.ts | 99 +++++ 7 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 .changeset/dull-spiders-help.md create mode 100644 packages/services/src/view-service/latest-swaps.test.ts create mode 100644 packages/services/src/view-service/latest-swaps.ts create mode 100644 packages/services/src/view-service/util/data.ts diff --git a/.changeset/dull-spiders-help.md b/.changeset/dull-spiders-help.md new file mode 100644 index 0000000000..78b9e8977a --- /dev/null +++ b/.changeset/dull-spiders-help.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/services': major +--- + +add latestSwaps view server method diff --git a/packages/protobuf/package.json b/packages/protobuf/package.json index fc5b4b6af1..1a52355565 100644 --- a/packages/protobuf/package.json +++ b/packages/protobuf/package.json @@ -16,7 +16,7 @@ "gen:ibc": "buf generate buf.build/cosmos/ibc:7ab44ae956a0488ea04e04511efa5f70", "gen:ics23": "buf generate buf.build/cosmos/ics23:55085f7c710a45f58fa09947208eb70b", "gen:noble": "buf generate buf.build/noble-assets/forwarding:5a8609a6772d417584a9c60cd8b80881", - "gen:penumbra": "buf generate buf.build/penumbra-zone/penumbra:649f1d61327144cb9a7e15c7ad210dcb", + "gen:penumbra": "buf generate buf.build/penumbra-zone/penumbra:adb116eefae84c1abd53a1594b895360", "lint": "eslint src", "lint:fix": "eslint src --fix", "lint:strict": "tsc --noEmit && eslint src --max-warnings 0", diff --git a/packages/services/src/view-service/assets.test.ts b/packages/services/src/view-service/assets.test.ts index b13a1ffc38..d64331090b 100644 --- a/packages/services/src/view-service/assets.test.ts +++ b/packages/services/src/view-service/assets.test.ts @@ -3,10 +3,11 @@ import { AssetsRequest, AssetsResponse } from '@penumbra-zone/protobuf/penumbra/ import { ViewService } from '@penumbra-zone/protobuf'; import { createContextValues, createHandlerContext, HandlerContext } from '@connectrpc/connect'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { ServicesInterface } from '@penumbra-zone/types/services'; import { servicesCtx } from '../ctx/prax.js'; import { assets } from './assets.js'; import { IndexedDbMock, MockServices } from '../test-utils.js'; -import type { ServicesInterface } from '@penumbra-zone/types/services'; +import { UM_METADATA } from './util/data.js'; describe('Assets request handler', () => { let req: AssetsRequest; @@ -187,21 +188,7 @@ const testData = [ inner: 'IYAlwlH0ld1wsRLlnYyl4ItsVeukLp4e7/U/Z+6opxA=', }, }), - Metadata.fromJson({ - description: '', - denomUnits: [ - { denom: 'penumbra', exponent: 6, aliases: [] }, - { denom: 'mpenumbra', exponent: 3, aliases: [] }, - { denom: 'upenumbra', exponent: 0, aliases: [] }, - ], - base: 'upenumbra', - display: 'penumbra', - name: '', - symbol: '', - penumbraAssetId: { - inner: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=', - }, - }), + UM_METADATA, Metadata.fromJson({ description: '', denomUnits: [ diff --git a/packages/services/src/view-service/index.ts b/packages/services/src/view-service/index.ts index 4b48874c98..9f5dfa7149 100644 --- a/packages/services/src/view-service/index.ts +++ b/packages/services/src/view-service/index.ts @@ -30,6 +30,7 @@ import { walletId } from './wallet-id.js'; import { witness } from './witness.js'; import { witnessAndBuild } from './witness-and-build.js'; import { transparentAddress } from './transparent-address.js'; +import { latestSwaps } from './latest-swaps.js'; export type Impl = ServiceImpl; @@ -64,4 +65,5 @@ export const viewImpl: Impl = { witness, witnessAndBuild, transparentAddress, + latestSwaps, }; diff --git a/packages/services/src/view-service/latest-swaps.test.ts b/packages/services/src/view-service/latest-swaps.test.ts new file mode 100644 index 0000000000..26be2dfa25 --- /dev/null +++ b/packages/services/src/view-service/latest-swaps.test.ts @@ -0,0 +1,344 @@ +import { ViewService } from '@penumbra-zone/protobuf'; +import { servicesCtx } from '../ctx/prax.js'; +import { fvkCtx } from '../ctx/full-viewing-key.js'; + +import { createContextValues, createHandlerContext, HandlerContext } from '@connectrpc/connect'; + +import { beforeEach, describe, expect, vi, it } from 'vitest'; + +import { + LatestSwapsRequest, + LatestSwapsResponse, + SwapRecord, +} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { IndexedDbMock, MockServices, testFullViewingKey } from '../test-utils.js'; +import type { ServicesInterface } from '@penumbra-zone/types/services'; +import { latestSwaps } from './latest-swaps.js'; +import { CommitmentSource } from '@penumbra-zone/protobuf/penumbra/core/component/sct/v1/sct_pb'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { getAddressByIndex } from '@penumbra-zone/wasm/keys'; +import { pnum } from '@penumbra-zone/types/pnum'; +import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; +import { SHITMOS_METADATA, UM_METADATA, USDC_METADATA } from './util/data.js'; +import { AddressIndex } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; +import { DirectedTradingPair } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; + +describe('LatestSwaps request handler', () => { + let mockServices: MockServices; + let mockCtx: HandlerContext; + let mockIndexedDb: IndexedDbMock; + let fillDB: (data: SwapRecord[]) => void; + + const request = async (req: LatestSwapsRequest): Promise => { + const responses: LatestSwapsResponse[] = []; + + for await (const res of latestSwaps(req, mockCtx)) { + responses.push(new LatestSwapsResponse(res)); + } + + return responses; + }; + + beforeEach(() => { + vi.resetAllMocks(); + + const mockIterateSwaps = { + next: vi.fn(), + [Symbol.asyncIterator]: () => mockIterateSwaps, + }; + + mockIndexedDb = { + iterateSwaps: () => mockIterateSwaps, + }; + + mockServices = { + getWalletServices: vi.fn(() => + Promise.resolve({ indexedDb: mockIndexedDb }), + ) as MockServices['getWalletServices'], + }; + + mockCtx = createHandlerContext({ + service: ViewService, + method: ViewService.methods.latestSwaps, + protocolName: 'mock', + requestMethod: 'MOCK', + url: '/mock', + contextValues: createContextValues() + .set(servicesCtx, () => Promise.resolve(mockServices as unknown as ServicesInterface)) + .set(fvkCtx, () => Promise.resolve(testFullViewingKey)), + }); + + fillDB = (data: SwapRecord[]) => { + for (const record of data) { + mockIterateSwaps.next.mockResolvedValueOnce({ + value: record, + }); + } + mockIterateSwaps.next.mockResolvedValueOnce({ + done: true, + }); + }; + }); + + it('collects swaps with "transaction" source only', async () => { + fillDB([ + IRRELEVANT_SWAP, + getSwap({ + height: 100, + account: 0, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 9, + account: 1, + from: UM_METADATA, + to: SHITMOS_METADATA, + input: 100n, + output: 110n, + }), + ]); + + const res = await request(new LatestSwapsRequest({})); + + expect(res.length).toBe(2); + expect(res[0]?.pair?.start?.equals(UM_METADATA.penumbraAssetId)).toBeTruthy(); + expect(res[0]?.pair?.end?.equals(USDC_METADATA.penumbraAssetId)).toBeTruthy(); + expect(res[0]?.blockHeight).toEqual(100n); + }); + + it('applies `responseLimit` filter correctly', async () => { + fillDB([ + getSwap({ + height: 100, + account: 0, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 9, + account: 1, + from: UM_METADATA, + to: SHITMOS_METADATA, + input: 100n, + output: 110n, + }), + ]); + + const res = await request( + new LatestSwapsRequest({ + responseLimit: 1n, + }), + ); + + expect(res.length).toBe(1); + expect(res[0]?.blockHeight).toEqual(100n); + }); + + it('applies `afterHeight` filter correctly', async () => { + fillDB([ + getSwap({ + height: 100, + account: 0, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 9, + account: 1, + from: UM_METADATA, + to: SHITMOS_METADATA, + input: 100n, + output: 110n, + }), + ]); + + const res = await request( + new LatestSwapsRequest({ + afterHeight: 50n, + }), + ); + + expect(res.length).toBe(1); + expect(res[0]?.blockHeight).toEqual(100n); + }); + + it('applies `accountFilter` filter correctly', async () => { + fillDB([ + getSwap({ + height: 100, + account: 0, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 9, + account: 1, + from: UM_METADATA, + to: SHITMOS_METADATA, + input: 100n, + output: 110n, + }), + ]); + + const res = await request( + new LatestSwapsRequest({ + accountFilter: new AddressIndex({ account: 0 }), + }), + ); + + expect(res.length).toBe(1); + }); + + it('applies `pair` filter correctly', async () => { + fillDB([ + getSwap({ + height: 100, + account: 0, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 9, + account: 1, + from: UM_METADATA, + to: SHITMOS_METADATA, + input: 100n, + output: 110n, + }), + ]); + + const res = await request( + new LatestSwapsRequest({ + pair: new DirectedTradingPair({ + start: UM_METADATA.penumbraAssetId, + end: USDC_METADATA.penumbraAssetId, + }), + }), + ); + + expect(res.length).toBe(1); + expect(res[0]?.pair?.start).toEqual(UM_METADATA.penumbraAssetId); + expect(res[0]?.pair?.end).toEqual(USDC_METADATA.penumbraAssetId); + }); + + it('applies all filters together correctly', async () => { + fillDB([ + IRRELEVANT_SWAP, + getSwap({ + height: 0, + account: 0, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 99, + account: 0, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 100, + account: 1, + from: UM_METADATA, + to: SHITMOS_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 101, + account: 1, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + getSwap({ + height: 102, + account: 1, + from: UM_METADATA, + to: USDC_METADATA, + input: 100n, + output: 110n, + }), + ]); + + const res = await request( + new LatestSwapsRequest({ + afterHeight: 50n, + responseLimit: 1n, + accountFilter: new AddressIndex({ account: 0 }), + pair: new DirectedTradingPair({ + start: UM_METADATA.penumbraAssetId, + end: USDC_METADATA.penumbraAssetId, + }), + }), + ); + + expect(res.length).toBe(1); + expect(res[0]?.blockHeight).toEqual(99n); + }); +}); + +interface GetSwapOptions { + height: number; + from: Metadata; + to: Metadata; + account: number; + input: bigint; + output: bigint; +} + +// Constructs correct SwapRecord with the most essential data needed for `latestSwaps` +const getSwap = ({ account, to, from, height, input, output }: GetSwapOptions) => { + return new SwapRecord({ + heightClaimed: BigInt(height), + swapCommitment: { inner: new Uint8Array([1, 2, 3]) }, + nullifier: { inner: new Uint8Array([1, 2, 3]) }, + source: new CommitmentSource({ + source: { + case: 'transaction', + value: { + id: new Uint8Array([1, 2, 3, 4, 5]), + }, + }, + }), + swap: { + claimAddress: getAddressByIndex(testFullViewingKey, account), + }, + outputData: { + tradingPair: { + asset1: from.penumbraAssetId, + asset2: to.penumbraAssetId, + }, + delta1: pnum(input, { exponent: getDisplayDenomExponent(from) }).toAmount(), + delta2: pnum(output, { exponent: getDisplayDenomExponent(to) }).toAmount(), + }, + }); +}; + +// Irrelevant – wrong source (should be 'transaction') +const IRRELEVANT_SWAP = new SwapRecord({ + swapCommitment: { inner: new Uint8Array([1, 2, 3]) }, + nullifier: { inner: new Uint8Array([1, 2, 3]) }, + source: new CommitmentSource({ + source: { + case: 'ics20Transfer', + value: { channelId: '', sender: '' }, + }, + }), +}); diff --git a/packages/services/src/view-service/latest-swaps.ts b/packages/services/src/view-service/latest-swaps.ts new file mode 100644 index 0000000000..931e576c59 --- /dev/null +++ b/packages/services/src/view-service/latest-swaps.ts @@ -0,0 +1,61 @@ +import type { Impl } from './index.js'; +import { servicesCtx } from '../ctx/prax.js'; +import { fvkCtx } from '../ctx/full-viewing-key.js'; +import { Value } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { TransactionId } from '@penumbra-zone/protobuf/penumbra/core/txhash/v1/txhash_pb'; +import { + DirectedTradingPair, + TradingPair, +} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { getAddressByIndex } from '@penumbra-zone/wasm/keys'; + +export const latestSwaps: Impl['latestSwaps'] = async function* (_req, ctx) { + const services = await ctx.values.get(servicesCtx)(); + const fvk = ctx.values.get(fvkCtx); + + const { indexedDb } = await services.getWalletServices(); + + const accountFilter = _req.accountFilter + ? getAddressByIndex(await fvk(), _req.accountFilter.account) + : undefined; + const pairFilter = _req.pair + ? new TradingPair({ asset1: _req.pair.start, asset2: _req.pair.end }) + : undefined; + + let counter = 0n; + + // yield BSOD existing swaps and unclaimed swaps, where an unclaimed swap + // is defined as `heightClaimed === 0n`. + for await (const swapRecord of indexedDb.iterateSwaps()) { + if (_req.responseLimit && counter >= _req.responseLimit) { + break; + } + + if ( + swapRecord.source?.source.case === 'transaction' && + swapRecord.swap?.claimAddress && + swapRecord.outputData?.tradingPair && + (!_req.afterHeight || swapRecord.heightClaimed > _req.afterHeight) && + (!accountFilter || swapRecord.swap.claimAddress.equals(accountFilter)) && + (!pairFilter || swapRecord.outputData.tradingPair.equals(pairFilter)) + ) { + counter++; + yield { + id: new TransactionId({ inner: swapRecord.source.source.value.id }), + blockHeight: swapRecord.heightClaimed, + pair: new DirectedTradingPair({ + start: swapRecord.outputData.tradingPair.asset1, + end: swapRecord.outputData.tradingPair.asset2, + }), + input: new Value({ + amount: swapRecord.outputData.delta1, + assetId: swapRecord.outputData.tradingPair.asset1, + }), + output: new Value({ + amount: swapRecord.outputData.delta2, + assetId: swapRecord.outputData.tradingPair.asset2, + }), + }; + } + } +}; diff --git a/packages/services/src/view-service/util/data.ts b/packages/services/src/view-service/util/data.ts new file mode 100644 index 0000000000..d4b2984923 --- /dev/null +++ b/packages/services/src/view-service/util/data.ts @@ -0,0 +1,99 @@ +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; + +export const UM_METADATA = Metadata.fromJson({ + description: 'The native token of Penumbra', + denomUnits: [ + { + denom: 'penumbra', + exponent: 6, + }, + { + denom: 'mpenumbra', + exponent: 3, + }, + { + denom: 'upenumbra', + }, + ], + base: 'upenumbra', + display: 'penumbra', + name: 'Penumbra', + symbol: 'UM', + penumbraAssetId: { + inner: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=', + }, + images: [ + { + svg: 'https://raw.githubusercontent.com/prax-wallet/registry/main/images/um.svg', + theme: { + primaryColorHex: '#c9a975', + }, + }, + ], + priorityScore: '999999999999', + coingeckoId: 'penumbra', +}); + +export const USDC_METADATA = Metadata.fromJson({ + description: 'USD Coin', + denomUnits: [ + { + denom: 'transfer/channel-2/uusdc', + }, + { + denom: 'transfer/channel-2/usdc', + exponent: 6, + }, + ], + base: 'transfer/channel-2/uusdc', + display: 'transfer/channel-2/usdc', + name: 'USDC', + symbol: 'USDC', + penumbraAssetId: { + inner: 'drPksQaBNYwSOzgfkGOEdrd4kEDkeALeh58Ps+7cjQs=', + }, + images: [ + { + png: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/_non-cosmos/ethereum/images/usdc.png', + svg: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/_non-cosmos/ethereum/images/usdc.svg', + theme: { + primaryColorHex: '#2775CA', + circle: true, + }, + }, + ], + priorityScore: '800000000100', + coingeckoId: 'usd-coin', +}); + +export const SHITMOS_METADATA = Metadata.fromJson({ + description: "The Cosmos Network's premier self-hatred memecoin.", + denomUnits: [ + { + denom: + 'transfer/channel-4/factory/osmo1q77cw0mmlluxu0wr29fcdd0tdnh78gzhkvhe4n6ulal9qvrtu43qtd0nh8/shitmos', + }, + { + denom: 'transfer/channel-4/SHITMOS', + exponent: 6, + }, + ], + base: 'transfer/channel-4/factory/osmo1q77cw0mmlluxu0wr29fcdd0tdnh78gzhkvhe4n6ulal9qvrtu43qtd0nh8/shitmos', + display: 'transfer/channel-4/SHITMOS', + name: 'Shitmos', + symbol: 'SHITMOS', + penumbraAssetId: { + inner: 'p6M59C5nGy2x3iJtRIPT5jA2ZhytFVTXX192/gTsHgA=', + }, + images: [ + { + png: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/shitmos.png', + svg: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/shitmos.svg', + theme: { + primaryColorHex: '#639BFF', + circle: true, + }, + }, + ], + priorityScore: '800000000096', +});