diff --git a/apps/extension/.env b/apps/extension/.env index ab5800bdde..e291e13932 100644 --- a/apps/extension/.env +++ b/apps/extension/.env @@ -1,6 +1,6 @@ PRAX=lkpmkhpnhknhmibgnmmhdhgdilepfghe -IDB_VERSION=28 +IDB_VERSION=29 USDC_ASSET_ID="reum7wQmk/owgvGMWMZn/6RFPV24zIKq3W6In/WwZgg=" MINIFRONT_URL=https://app.testnet.penumbra.zone/ PENUMBRA_NODE_PD_URL=https://grpc.testnet.penumbra.zone/ diff --git a/apps/extension/src/service-worker.ts b/apps/extension/src/service-worker.ts index 6b65030253..c5bbc0b2fc 100644 --- a/apps/extension/src/service-worker.ts +++ b/apps/extension/src/service-worker.ts @@ -48,6 +48,7 @@ const startServices = async () => { grpcEndpoint, walletId: wallet0.id, fullViewingKey: wallet0.fullViewingKey, + numeraireAssetId: USDC_ASSET_ID, }); await services.initialize(); return services; diff --git a/packages/getters/src/batch-swap-output-data.ts b/packages/getters/src/batch-swap-output-data.ts new file mode 100644 index 0000000000..27aa676c0a --- /dev/null +++ b/packages/getters/src/batch-swap-output-data.ts @@ -0,0 +1,14 @@ +import { createGetter } from './utils/create-getter'; +import { getAsset1, getAsset2 } from './trading-pair'; +import { BatchSwapOutputData } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb'; + +export const getTradingPair = createGetter((b?: BatchSwapOutputData) => b?.tradingPair); +export const getSwapAsset1 = getTradingPair.pipe(getAsset1); +export const getSwapAsset2 = getTradingPair.pipe(getAsset2); + +export const getDelta1Amount = createGetter((b?: BatchSwapOutputData) => b?.delta1); +export const getDelta2Amount = createGetter((b?: BatchSwapOutputData) => b?.delta2); +export const getLambda1Amount = createGetter((b?: BatchSwapOutputData) => b?.lambda1); +export const getLambda2Amount = createGetter((b?: BatchSwapOutputData) => b?.lambda2); +export const getUnfilled1Amount = createGetter((b?: BatchSwapOutputData) => b?.unfilled1); +export const getUnfilled2Amount = createGetter((b?: BatchSwapOutputData) => b?.unfilled2); diff --git a/packages/query/package.json b/packages/query/package.json index 03dab22b0f..f4ffc9a446 100644 --- a/packages/query/package.json +++ b/packages/query/package.json @@ -10,6 +10,7 @@ "dependencies": { "@penumbra-zone/crypto-web": "workspace:*", "@penumbra-zone/constants": "workspace:*", + "@penumbra-zone/getters": "workspace:*", "@penumbra-zone/wasm": "workspace:*", "@penumbra-zone/types": "workspace:*", "exponential-backoff": "^3.1.1" diff --git a/packages/query/src/block-processor.ts b/packages/query/src/block-processor.ts index 955b9c7c44..c85eb7a0a4 100644 --- a/packages/query/src/block-processor.ts +++ b/packages/query/src/block-processor.ts @@ -22,11 +22,13 @@ import type { BlockProcessorInterface } from '@penumbra-zone/types/src/block-pro import type { IndexedDbInterface } from '@penumbra-zone/types/src/indexed-db'; import type { ViewServerInterface } from '@penumbra-zone/types/src/servers'; import { customizeSymbol } from '@penumbra-zone/types/src/customize-symbol'; +import { updatePrices } from './price-indexer'; interface QueryClientProps { querier: RootQuerier; indexedDb: IndexedDbInterface; viewServer: ViewServerInterface; + numeraireAssetId: string; } const blankTxSource = new CommitmentSource({ @@ -38,12 +40,14 @@ export class BlockProcessor implements BlockProcessorInterface { private readonly indexedDb: IndexedDbInterface; private readonly viewServer: ViewServerInterface; private readonly abortController: AbortController = new AbortController(); + private readonly numeraireAssetId: string; private syncPromise: Promise | undefined; - constructor({ indexedDb, viewServer, querier }: QueryClientProps) { + constructor({ indexedDb, viewServer, querier, numeraireAssetId }: QueryClientProps) { this.indexedDb = indexedDb; this.viewServer = viewServer; this.querier = querier; + this.numeraireAssetId = numeraireAssetId; } // If syncBlocks() is called multiple times concurrently, they'll all wait for @@ -232,6 +236,18 @@ export class BlockProcessor implements BlockProcessorInterface { await this.saveTransactions(compactBlock.height, relevantTx); } + // we can't use third-party price oracles for privacy reasons, + // so we have to get asset prices from swap results during block scans + // and store them locally in indexed-db. + if (compactBlock.swapOutputs.length) { + await updatePrices( + this.indexedDb, + this.numeraireAssetId, + compactBlock.swapOutputs, + compactBlock.height, + ); + } + // We only query Tendermint for the latest known block height once, when // the block processor starts running. Once we're caught up, though, the // chain will of course continue adding blocks, and we'll keep processing diff --git a/packages/query/src/price-indexer.test.ts b/packages/query/src/price-indexer.test.ts new file mode 100644 index 0000000000..c11831b346 --- /dev/null +++ b/packages/query/src/price-indexer.test.ts @@ -0,0 +1,115 @@ +import { updatePrices } from './price-indexer'; +import { BatchSwapOutputData } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { IndexedDbInterface } from '@penumbra-zone/types/src/indexed-db'; +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { base64ToUint8Array } from '@penumbra-zone/types/src/base64'; + +describe('update prices', () => { + let indexedDbMock: IndexedDbInterface; + const updatePriceMock: Mock = vi.fn(); + const height = 123n; + const numeraireAssetId = 'reum7wQmk/owgvGMWMZn/6RFPV24zIKq3W6In/WwZgg='; + const numeraireAsset: AssetId = new AssetId({ + inner: base64ToUint8Array(numeraireAssetId), + }); + beforeEach(() => { + vi.clearAllMocks(); + + indexedDbMock = { + updatePrice: updatePriceMock, + } as unknown as IndexedDbInterface; + }); + + it('should update prices correctly for a swapOutput with NUMERAIRE as swapAsset2', async () => { + const asset1 = new AssetId({ inner: new Uint8Array(12) }); + const swapOutputs: BatchSwapOutputData[] = [ + new BatchSwapOutputData({ + tradingPair: { + asset1: asset1, + asset2: numeraireAsset, + }, + delta1: { lo: 250n }, + lambda2: { lo: 1200n }, + unfilled1: { lo: 0n }, + }), + ]; + + await updatePrices(indexedDbMock, numeraireAssetId, swapOutputs, height); + expect(updatePriceMock).toBeCalledTimes(1); + expect(updatePriceMock).toBeCalledWith(asset1, numeraireAsset, 4.8, height); + }); + + it('should update prices correctly for a swapOutput with NUMERAIRE as swapAsset1', async () => { + const asset1 = new AssetId({ inner: new Uint8Array(12) }); + const swapOutputs: BatchSwapOutputData[] = [ + new BatchSwapOutputData({ + tradingPair: { + asset1: numeraireAsset, + asset2: asset1, + }, + delta2: { lo: 40n }, + lambda1: { lo: 12740n }, + unfilled2: { lo: 0n }, + }), + ]; + + await updatePrices(indexedDbMock, numeraireAssetId, swapOutputs, height); + expect(updatePriceMock).toBeCalledTimes(1); + expect(updatePriceMock).toBeCalledWith(asset1, numeraireAsset, 318.5, height); + }); + + it('should not update prices if delta is zero', async () => { + const asset1 = new AssetId({ inner: new Uint8Array(12) }); + const swapOutputs: BatchSwapOutputData[] = [ + new BatchSwapOutputData({ + tradingPair: { + asset1: numeraireAsset, + asset2: asset1, + }, + delta2: { lo: 0n }, + lambda1: { lo: 12740n }, + unfilled2: { lo: 0n }, + }), + ]; + + await updatePrices(indexedDbMock, numeraireAssetId, swapOutputs, height); + expect(updatePriceMock).toBeCalledTimes(0); + }); + + it('should update prices correctly for partially filled', async () => { + const asset1 = new AssetId({ inner: new Uint8Array(12) }); + const swapOutputs: BatchSwapOutputData[] = [ + new BatchSwapOutputData({ + tradingPair: { + asset1: asset1, + asset2: numeraireAsset, + }, + delta1: { lo: 250n }, + lambda2: { lo: 1200n }, + unfilled1: { lo: 100n }, + }), + ]; + await updatePrices(indexedDbMock, numeraireAssetId, swapOutputs, height); + expect(updatePriceMock).toBeCalledTimes(1); + expect(updatePriceMock).toBeCalledWith(asset1, numeraireAsset, 8, height); + }); + + it('should not update prices if swap is fully unfilled', async () => { + const asset1 = new AssetId({ inner: new Uint8Array(12) }); + const swapOutputs: BatchSwapOutputData[] = [ + new BatchSwapOutputData({ + tradingPair: { + asset1: numeraireAsset, + asset2: asset1, + }, + delta2: { lo: 100n }, + lambda1: { lo: 12740n }, + unfilled2: { lo: 100n }, + }), + ]; + + await updatePrices(indexedDbMock, numeraireAssetId, swapOutputs, height); + expect(updatePriceMock).toBeCalledTimes(0); + }); +}); diff --git a/packages/query/src/price-indexer.ts b/packages/query/src/price-indexer.ts new file mode 100644 index 0000000000..6ef8156d8c --- /dev/null +++ b/packages/query/src/price-indexer.ts @@ -0,0 +1,91 @@ +import { BatchSwapOutputData } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb'; +import { IndexedDbInterface } from '@penumbra-zone/types/src/indexed-db'; +import { divideAmounts, isZero, subtractAmounts } from '@penumbra-zone/types/src/amount'; +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; +import { + getDelta1Amount, + getDelta2Amount, + getLambda1Amount, + getLambda2Amount, + getSwapAsset1, + getSwapAsset2, + getUnfilled1Amount, + getUnfilled2Amount, +} from '@penumbra-zone/getters/src/batch-swap-output-data'; +import { base64ToUint8Array } from '@penumbra-zone/types/src/base64'; + +/** + * + * @param delta - total amount of 'pricedAsset' that was input to the batch swap + * @param unfilled - total amount of 'pricedAsset' that was returned unfilled + * @param lambda - total amount of 'numeraire' that was output from the batch swap + * Price formula: + * price = (lambda)/(delta - unfilled) + * The price cannot be calculated if + * - lambda is zero + * - delta is zero + * - (delta - unfilled) is zero + * @return 0 if the price cannot be calculated and some positive number if the price has been calculated. + */ +export const calculatePrice = (delta: Amount, unfilled: Amount, lambda: Amount): number => { + const filledAmount = subtractAmounts(delta, unfilled); + // + return isZero(delta) || isZero(lambda) || isZero(filledAmount) + ? 0 + : divideAmounts(lambda, filledAmount).toNumber(); +}; + +/** + * Each 'BatchSwapOutputData' (BSOD) can generate up to two prices + * Each BSOD in block has a unique trading pair + * Trading pair has a canonical ordering, there's only one trading pair per pair of assets + * Each BSOD can generate up to two prices + * 1. pricedAsset -> numeraire (selling price) + * 2. numeraire -> pricedAsset (buying price) + * This function processes only (1) price and ignores (2) price + * We can get a BSOD with zero deltas(inputs), and we shouldn't save the price in that case + */ +export const updatePrices = async ( + indexedDb: IndexedDbInterface, + numeraireAssetId: string, + swapOutputs: BatchSwapOutputData[], + height: bigint, +) => { + const numeraireAsset: AssetId = new AssetId({ + inner: base64ToUint8Array(numeraireAssetId), + }); + + for (const swapOutput of swapOutputs) { + const swapAsset1 = getSwapAsset1(swapOutput); + const swapAsset2 = getSwapAsset2(swapOutput); + + let numerairePerUnit = 0; + let pricedAsset: AssetId | undefined = undefined; + + // case for trading pair + if (swapAsset2.equals(numeraireAsset)) { + pricedAsset = swapAsset1; + // numerairePerUnit = lambda2/(delta1-unfilled1) + numerairePerUnit = calculatePrice( + getDelta1Amount(swapOutput), + getUnfilled1Amount(swapOutput), + getLambda2Amount(swapOutput), + ); + } + // case for trading pair + else if (swapAsset1.equals(numeraireAsset)) { + pricedAsset = swapAsset2; + // numerairePerUnit = lambda1/(delta2-unfilled2) + numerairePerUnit = calculatePrice( + getDelta2Amount(swapOutput), + getUnfilled2Amount(swapOutput), + getLambda1Amount(swapOutput), + ); + } + + if (pricedAsset === undefined || numerairePerUnit === 0) continue; + + await indexedDb.updatePrice(pricedAsset, numeraireAsset, numerairePerUnit, height); + } +}; diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index 150611963b..738dfb56b2 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -14,10 +14,15 @@ export interface ServicesConfig { readonly grpcEndpoint?: string; readonly walletId?: string; readonly fullViewingKey?: string; + readonly numeraireAssetId: string; } const isCompleteServicesConfig = (c: Partial): c is Required => - c.grpcEndpoint != null && c.idbVersion != null && c.walletId != null && c.fullViewingKey != null; + c.grpcEndpoint != null && + c.idbVersion != null && + c.walletId != null && + c.fullViewingKey != null && + c.numeraireAssetId != null; export class Services implements ServicesInterface { private walletServicesPromise: Promise | undefined; @@ -83,7 +88,7 @@ export class Services implements ServicesInterface { } private async initializeWalletServices(): Promise { - const { walletId, fullViewingKey, idbVersion: dbVersion } = await this.config; + const { walletId, fullViewingKey, idbVersion: dbVersion, numeraireAssetId } = await this.config; const params = await this.querier.app.appParams(); if (!params.sctParams?.epochDuration) throw new Error('Epoch duration unknown'); const { @@ -110,6 +115,7 @@ export class Services implements ServicesInterface { viewServer, querier: this.querier, indexedDb, + numeraireAssetId, }); return { viewServer, blockProcessor, indexedDb, querier: this.querier }; diff --git a/packages/storage/src/indexed-db/index.ts b/packages/storage/src/indexed-db/index.ts index d5644447c7..f969ed63f2 100644 --- a/packages/storage/src/indexed-db/index.ts +++ b/packages/storage/src/indexed-db/index.ts @@ -14,6 +14,7 @@ import { } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { AssetId, + EstimatedPrice, Metadata, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; import { StateCommitment } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/crypto/tct/v1/tct_pb'; @@ -104,6 +105,7 @@ export class IndexedDb implements IndexedDbInterface { db.createObjectStore('POSITIONS', { keyPath: 'id.inner' }); db.createObjectStore('EPOCHS', { autoIncrement: true }); db.createObjectStore('VALIDATOR_INFOS'); + db.createObjectStore('PRICES', { keyPath: ['pricedAsset.inner', 'numeraire.inner'] }); }, }); const constants = { @@ -536,6 +538,25 @@ export class IndexedDb implements IndexedDbInterface { ); } + async updatePrice( + pricedAsset: AssetId, + numeraire: AssetId, + numerairePerUnit: number, + height: bigint, + ) { + const estimatedPrice = new EstimatedPrice({ + pricedAsset, + numeraire, + numerairePerUnit, + asOfHeight: height, + }); + + await this.u.update({ + table: 'PRICES', + value: estimatedPrice.toJson() as Jsonified, + }); + } + private addSctUpdates(txs: IbdUpdates, sctUpdates: ScanBlockResult['sctUpdates']): void { if (sctUpdates.set_position) { txs.add({ diff --git a/packages/types/src/amount.test.ts b/packages/types/src/amount.test.ts index 33e0bf4811..6ce8e31f95 100644 --- a/packages/types/src/amount.test.ts +++ b/packages/types/src/amount.test.ts @@ -3,10 +3,12 @@ import { addAmounts, displayAmount, displayUsd, + divideAmounts, fromBaseUnitAmount, fromValueView, isZero, joinLoHiAmount, + subtractAmounts, } from './amount'; import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; import { @@ -114,6 +116,68 @@ describe('addAmounts', () => { }); }); +describe('subtractAmounts', () => { + it('should return an Amount with lo and hi equal to 0 when both inputs are 0', () => { + const a = new Amount({ lo: 0n, hi: 0n }); + const b = new Amount({ lo: 0n, hi: 0n }); + + const result = subtractAmounts(a, b); + + expect(result.lo).toBe(0n); + expect(result.hi).toBe(0n); + }); + + it('should correctly subtract two Amounts', () => { + const a = new Amount({ lo: 2n, hi: 2n }); + const b = new Amount({ lo: 1n, hi: 1n }); + + const result = subtractAmounts(a, b); + + expect(result.lo).toBe(1n); + expect(result.hi).toBe(1n); + }); + + it('should throw an error if minuend is less than subtrahend', () => { + const a = new Amount({ lo: 1n, hi: 1n }); + const b = new Amount({ lo: 2n, hi: 2n }); + + expect(() => subtractAmounts(a, b)).toThrow('Amount cannot be negative'); + }); +}); + +describe('divideAmounts', () => { + it('should throw an error when dividing by zero', () => { + const a = new Amount({ lo: 1n, hi: 1n }); + const b = new Amount({ lo: 0n, hi: 0n }); + + expect(() => divideAmounts(a, b)).toThrow('Division by zero'); + }); + + it('should return 0n if dividend is zero', () => { + const a = new Amount({ lo: 0n, hi: 0n }); + const b = new Amount({ lo: 18446744073709551615n, hi: 1n }); + const result = divideAmounts(a, b); + + expect(result.isZero()).toBeTruthy(); + }); + + it('should return a number without fractions when dividing without remainder', () => { + const a = new Amount({ lo: 6n, hi: 0n }); + const b = new Amount({ lo: 2n, hi: 0n }); + const result = divideAmounts(a, b); + + expect(result.toNumber()).toBe(3); + }); + + it('should return a number with specified precision', () => { + const a = new Amount({ lo: 10n, hi: 0n }); + const b = new Amount({ lo: 3n, hi: 0n }); + const result = divideAmounts(a, b); + + expect(result.toFixed(3)).toEqual('3.333'); + }); +}); + describe('Formatting', () => { describe('displayAmount()', () => { it('no decimals', () => { diff --git a/packages/types/src/amount.ts b/packages/types/src/amount.ts index 728d91d802..f49eca43a3 100644 --- a/packages/types/src/amount.ts +++ b/packages/types/src/amount.ts @@ -29,6 +29,26 @@ export const addAmounts = (a: Amount, b: Amount): Amount => { return new Amount({ lo, hi }); }; +export const subtractAmounts = (minuend: Amount, subtrahend: Amount): Amount => { + const joinedMinuend = joinLoHiAmount(minuend); + const joinedSubtrahend = joinLoHiAmount(subtrahend); + + if (joinedSubtrahend > joinedMinuend) throw new Error('Amount cannot be negative'); + + const joined = joinedMinuend - joinedSubtrahend; + const { lo, hi } = splitLoHi(joined); + return new Amount({ lo, hi }); +}; + +export const divideAmounts = (dividend: Amount, divider: Amount): BigNumber => { + if (isZero(divider)) throw new Error('Division by zero'); + + const joinedDividend = new BigNumber(joinLoHiAmount(dividend).toString()); + const joinedDivider = new BigNumber(joinLoHiAmount(divider).toString()); + + return joinedDividend.dividedBy(joinedDivider); +}; + // This function takes a number and formats it in a display-friendly way (en-US locale) // Examples: // 2000 -> 2,000 diff --git a/packages/types/src/indexed-db.ts b/packages/types/src/indexed-db.ts index 0389cb07bb..16c33e1b8d 100644 --- a/packages/types/src/indexed-db.ts +++ b/packages/types/src/indexed-db.ts @@ -16,6 +16,7 @@ import { } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { AssetId, + EstimatedPrice, Metadata, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; import { @@ -92,6 +93,12 @@ export interface IndexedDbInterface { getEpochByHeight(height: bigint): Promise; upsertValidatorInfo(validatorInfo: ValidatorInfo): Promise; iterateValidatorInfos(): AsyncGenerator; + updatePrice( + pricedAsset: AssetId, + numeraire: AssetId, + numerairePerUnit: number, + height: bigint, + ): Promise; } export interface PenumbraDb extends DBSchema { @@ -171,6 +178,13 @@ export interface PenumbraDb extends DBSchema { key: string; // bech32-encoded validator identity key value: Jsonified; }; + PRICES: { + key: [ + Jsonified['pricedAsset']['inner']>, + Jsonified['numeraire']['inner']>, + ]; // composite key + value: Jsonified; + }; } // need to store PositionId and Position in the same table diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 658678c092..eb3d7cdd62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,9 @@ importers: '@penumbra-zone/crypto-web': specifier: workspace:* version: link:../crypto + '@penumbra-zone/getters': + specifier: workspace:* + version: link:../getters '@penumbra-zone/types': specifier: workspace:* version: link:../types