diff --git a/apps/extension/package.json b/apps/extension/package.json index d7d53e29..bf1aba92 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -22,6 +22,7 @@ "@penumbra-zone/bech32m": "workspace:*", "@penumbra-zone/client": "workspace:*", "@penumbra-zone/crypto-web": "workspace:*", + "@penumbra-zone/getters": "workspace:*", "@penumbra-zone/perspective": "workspace:*", "@penumbra-zone/protobuf": "workspace:*", "@penumbra-zone/query": "workspace:*", diff --git a/apps/extension/src/icons/numeraires-gradient.tsx b/apps/extension/src/icons/numeraires-gradient.tsx new file mode 100644 index 00000000..05766d33 --- /dev/null +++ b/apps/extension/src/icons/numeraires-gradient.tsx @@ -0,0 +1,24 @@ +export const NumerairesGradientIcon = () => ( + + + + + + + + + + +); diff --git a/apps/extension/src/message/services.ts b/apps/extension/src/message/services.ts index 1eede672..23de08e4 100644 --- a/apps/extension/src/message/services.ts +++ b/apps/extension/src/message/services.ts @@ -1,3 +1,4 @@ export enum ServicesMessage { ClearCache = 'ClearCache', + ChangeNumeraires = 'ChangeNumeraires', } diff --git a/apps/extension/src/routes/page/onboarding/default-frontend.tsx b/apps/extension/src/routes/page/onboarding/default-frontend.tsx index 959c32d4..349a3825 100644 --- a/apps/extension/src/routes/page/onboarding/default-frontend.tsx +++ b/apps/extension/src/routes/page/onboarding/default-frontend.tsx @@ -10,7 +10,7 @@ export const SetDefaultFrontendPage = () => { const onSubmit: FormEventHandler = (event): void => { event.preventDefault(); - navigate(PagePath.ONBOARDING_SUCCESS); + navigate(PagePath.SET_NUMERAIRES); }; return ( diff --git a/apps/extension/src/routes/page/onboarding/routes.tsx b/apps/extension/src/routes/page/onboarding/routes.tsx index 84d2fd0f..05d1726b 100644 --- a/apps/extension/src/routes/page/onboarding/routes.tsx +++ b/apps/extension/src/routes/page/onboarding/routes.tsx @@ -8,6 +8,7 @@ import { SetPassword } from './set-password'; import { pageIndexLoader } from '..'; import { SetGrpcEndpoint } from './set-grpc-endpoint'; import { SetDefaultFrontendPage } from './default-frontend'; +import { SetNumerairesPage } from './set-numeraire'; export const onboardingRoutes = [ { @@ -38,6 +39,10 @@ export const onboardingRoutes = [ path: PagePath.SET_DEFAULT_FRONTEND, element: , }, + { + path: PagePath.SET_NUMERAIRES, + element: , + }, { path: PagePath.ONBOARDING_SUCCESS, element: , diff --git a/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx b/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx index c79df697..46c1f854 100644 --- a/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx +++ b/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx @@ -16,6 +16,7 @@ export const SetGrpcEndpoint = () => { Select your preferred RPC endpoint + The requests you make may reveal your intentions about transactions you wish to make, so select an RPC node that you trust. If you're unsure which one to choose, leave this diff --git a/apps/extension/src/routes/page/onboarding/set-numeraire.tsx b/apps/extension/src/routes/page/onboarding/set-numeraire.tsx new file mode 100644 index 00000000..eb347b14 --- /dev/null +++ b/apps/extension/src/routes/page/onboarding/set-numeraire.tsx @@ -0,0 +1,30 @@ +import { Card, CardDescription, CardHeader, CardTitle } from '@penumbra-zone/ui/components/ui/card'; +import { FadeTransition } from '@penumbra-zone/ui/components/ui/fade-transition'; +import { usePageNav } from '../../../utils/navigate'; +import { PagePath } from '../paths'; +import { NumeraireForm } from '../../../shared/components/numeraires-form'; + +export const SetNumerairesPage = () => { + const navigate = usePageNav(); + + const onSuccess = (): void => { + navigate(PagePath.ONBOARDING_SUCCESS); + }; + + return ( + + + + In which token denomination would you prefer to price assets? + + Prax does not use third-party price providers for privacy reasons. Instead, Prax indexes + asset prices locally by selected denomination. + + +
+ +
+
+
+ ); +}; diff --git a/apps/extension/src/routes/page/paths.ts b/apps/extension/src/routes/page/paths.ts index e965074c..a501389c 100644 --- a/apps/extension/src/routes/page/paths.ts +++ b/apps/extension/src/routes/page/paths.ts @@ -8,6 +8,7 @@ export enum PagePath { SET_PASSWORD = '/welcome/set-password', SET_GRPC_ENDPOINT = '/welcome/set-grpc-endpoint', SET_DEFAULT_FRONTEND = '/welcome/set-default-frontend', + SET_NUMERAIRES = '/welcome/set-numeraires', RESTORE_PASSWORD = '/restore-password', RESTORE_PASSWORD_INDEX = '/restore-password/', RESTORE_PASSWORD_SET_PASSWORD = '/restore-password/set-password', diff --git a/apps/extension/src/routes/popup/paths.ts b/apps/extension/src/routes/popup/paths.ts index 22825517..c3555084 100644 --- a/apps/extension/src/routes/popup/paths.ts +++ b/apps/extension/src/routes/popup/paths.ts @@ -14,4 +14,5 @@ export enum PopupPath { SETTINGS_RECOVERY_PASSPHRASE = '/settings/recovery-passphrase', SETTINGS_FULL_VIEWING_KEY = '/settings/full-viewing-key', SETTINGS_SPEND_KEY = '/settings/spend-key', + SETTINGS_NUMERAIRES = '/settings/numeraires', } diff --git a/apps/extension/src/routes/popup/settings/routes.tsx b/apps/extension/src/routes/popup/settings/routes.tsx index b0de683c..357c0f9f 100644 --- a/apps/extension/src/routes/popup/settings/routes.tsx +++ b/apps/extension/src/routes/popup/settings/routes.tsx @@ -10,6 +10,7 @@ import { SettingsRPC } from './settings-rpc'; import { SettingsSecurity } from './settings-security'; import { SettingsSpendKey } from './settings-spend-key'; import { SettingsDefaultFrontend } from './settings-default-frontend'; +import { SettingsNumeraires } from './settings-numeraires'; export const settingsRoutes = [ { @@ -56,4 +57,8 @@ export const settingsRoutes = [ path: PopupPath.SETTINGS_SPEND_KEY, element: , }, + { + path: PopupPath.SETTINGS_NUMERAIRES, + element: , + }, ]; diff --git a/apps/extension/src/routes/popup/settings/settings-numeraires.tsx b/apps/extension/src/routes/popup/settings/settings-numeraires.tsx new file mode 100644 index 00000000..9ad1902d --- /dev/null +++ b/apps/extension/src/routes/popup/settings/settings-numeraires.tsx @@ -0,0 +1,18 @@ +import { SettingsScreen } from './settings-screen'; +import { NumerairesGradientIcon } from '../../../icons/numeraires-gradient'; +import { usePopupNav } from '../../../utils/navigate'; +import { PopupPath } from '../paths'; +import { NumeraireForm } from '../../../shared/components/numeraires-form'; + +export const SettingsNumeraires = () => { + const navigate = usePopupNav(); + + const onSuccess = () => { + navigate(PopupPath.INDEX); + }; + return ( + + + + ); +}; diff --git a/apps/extension/src/routes/popup/settings/settings.tsx b/apps/extension/src/routes/popup/settings/settings.tsx index ffe20a74..a2c56f47 100644 --- a/apps/extension/src/routes/popup/settings/settings.tsx +++ b/apps/extension/src/routes/popup/settings/settings.tsx @@ -1,4 +1,5 @@ import { + BarChartIcon, DashboardIcon, ExitIcon, HomeIcon, @@ -34,6 +35,11 @@ const links = [ icon: , href: PopupPath.SETTINGS_CONNECTED_SITES, }, + { + title: 'Price denomination', + icon: , + href: PopupPath.SETTINGS_NUMERAIRES, + }, { title: 'Advanced', icon: , diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts index a234cf32..2b06bf6a 100644 --- a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts +++ b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts @@ -12,7 +12,9 @@ import { isValidUrl } from '../../utils/is-valid-url'; const useSaveGrpcEndpointSelector = (state: AllSlices) => ({ grpcEndpoint: state.network.grpcEndpoint, + chainId: state.network.chainId, setGrpcEndpoint: state.network.setGRPCEndpoint, + setChainId: state.network.setChainId, }); const getRpcsFromRegistry = () => { @@ -25,10 +27,11 @@ export const useGrpcEndpointForm = () => { const grpcEndpoints = useMemo(() => getRpcsFromRegistry(), []); // Get the rpc set in storage (if present) - const { grpcEndpoint, setGrpcEndpoint } = useStoreShallow(useSaveGrpcEndpointSelector); + const { grpcEndpoint, chainId, setGrpcEndpoint, setChainId } = useStoreShallow( + useSaveGrpcEndpointSelector, + ); const [originalChainId, setOriginalChainId] = useState(); - const [chainId, setChainId] = useState(); const [grpcEndpointInput, setGrpcEndpointInput] = useState(''); const [rpcError, setRpcError] = useState(); const [isSubmitButtonEnabled, setIsSubmitButtonEnabled] = useState(false); diff --git a/apps/extension/src/shared/components/numeraires-form.tsx b/apps/extension/src/shared/components/numeraires-form.tsx new file mode 100644 index 00000000..71b1e906 --- /dev/null +++ b/apps/extension/src/shared/components/numeraires-form.tsx @@ -0,0 +1,96 @@ +import { ChainRegistryClient } from '@penumbra-labs/registry'; +import { AllSlices, useStore } from '../../state'; +import { useChainIdQuery } from '../../hooks/chain-id'; +import { useMemo, useState } from 'react'; +import { ServicesMessage } from '../../message/services'; +import { SelectList } from '@penumbra-zone/ui/components/ui/select-list'; +import { bech32mAssetId } from '@penumbra-zone/bech32m/passet'; +import { getAssetId } from '@penumbra-zone/getters/metadata'; +import { Button } from '@penumbra-zone/ui/components/ui/button'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; + +const getNumeraireFromRegistry = (chainId?: string): Metadata[] => { + if (!chainId) return []; + const registryClient = new ChainRegistryClient(); + const registry = registryClient.get(chainId); + return registry.numeraires.map(n => registry.getMetadata(n)); +}; + +const useNumerairesSelector = (state: AllSlices) => { + return { + selectedNumeraires: state.numeraires.selectedNumeraires, + selectNumeraire: state.numeraires.selectNumeraire, + saveNumeraires: state.numeraires.saveNumeraires, + networkChainId: state.network.chainId, + }; +}; + +export const NumeraireForm = ({ + isOnboarding, + onSuccess, +}: { + isOnboarding?: boolean; + onSuccess: () => void | Promise; +}) => { + const { chainId } = useChainIdQuery(); + const { selectedNumeraires, selectNumeraire, saveNumeraires, networkChainId } = + useStore(useNumerairesSelector); + + // 'chainId' from 'useChainIdQuery' is not available during onboarding, + // this forces you to use two sources to guarantee 'chainId' for both settings and onboarding + const numeraires = useMemo(() => getNumeraireFromRegistry(chainId ?? networkChainId), [chainId]); + + const [loading, setLoading] = useState(false); + + const handleSubmit = () => { + setLoading(true); + void (async function () { + await saveNumeraires(); + await chrome.runtime.sendMessage(ServicesMessage.ChangeNumeraires); + await onSuccess(); + })(); + }; + + return ( +
+
+ + {numeraires.map(metadata => { + // Image default is "" and thus cannot do nullish-coalescing + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const icon = metadata.images[0]?.png || metadata.images[0]?.svg; + return ( + selectNumeraire(getAssetId(metadata).toJsonString())} + image={ + !!icon && ( + rpc endpoint brand image + ) + } + /> + ); + })} + + + +
+
+ ); +}; diff --git a/apps/extension/src/state/index.ts b/apps/extension/src/state/index.ts index 945760df..201d7033 100644 --- a/apps/extension/src/state/index.ts +++ b/apps/extension/src/state/index.ts @@ -13,12 +13,14 @@ import { createTxApprovalSlice, TxApprovalSlice } from './tx-approval'; import { createOriginApprovalSlice, OriginApprovalSlice } from './origin-approval'; import { ConnectedSitesSlice, createConnectedSitesSlice } from './connected-sites'; import { createDefaultFrontendSlice, DefaultFrontendSlice } from './default-frontend'; +import { createNumerairesSlice, NumerairesSlice } from './numeraires'; export interface AllSlices { wallets: WalletsSlice; password: PasswordSlice; seedPhrase: SeedPhraseSlice; network: NetworkSlice; + numeraires: NumerairesSlice; txApproval: TxApprovalSlice; originApproval: OriginApprovalSlice; connectedSites: ConnectedSitesSlice; @@ -41,6 +43,7 @@ export const initializeStore = ( password: createPasswordSlice(session, local)(setState, getState, store), seedPhrase: createSeedPhraseSlice(setState, getState, store), network: createNetworkSlice(local)(setState, getState, store), + numeraires: createNumerairesSlice(local)(setState, getState, store), connectedSites: createConnectedSitesSlice(local)(setState, getState, store), txApproval: createTxApprovalSlice()(setState, getState, store), originApproval: createOriginApprovalSlice()(setState, getState, store), diff --git a/apps/extension/src/state/network.ts b/apps/extension/src/state/network.ts index f67b819e..db6ebee9 100644 --- a/apps/extension/src/state/network.ts +++ b/apps/extension/src/state/network.ts @@ -5,7 +5,9 @@ import { AllSlices, SliceCreator } from '.'; export interface NetworkSlice { grpcEndpoint: string | undefined; fullSyncHeight?: number; + chainId?: string; setGRPCEndpoint: (endpoint: string) => Promise; + setChainId: (chainId: string) => void; } export const createNetworkSlice = @@ -14,6 +16,7 @@ export const createNetworkSlice = return { grpcEndpoint: undefined, fullSyncHeight: undefined, + chainId: undefined, setGRPCEndpoint: async (endpoint: string) => { set(state => { state.network.grpcEndpoint = endpoint; @@ -21,6 +24,11 @@ export const createNetworkSlice = await local.set('grpcEndpoint', endpoint); }, + setChainId: (chainId: string) => { + set(state => { + state.network.chainId = chainId; + }); + }, }; }; diff --git a/apps/extension/src/state/numeraires.ts b/apps/extension/src/state/numeraires.ts new file mode 100644 index 00000000..b3aae19d --- /dev/null +++ b/apps/extension/src/state/numeraires.ts @@ -0,0 +1,34 @@ +import { LocalStorageState } from '../storage/types'; +import { AllSlices, SliceCreator } from '.'; +import { ExtensionStorage } from '../storage/base'; +import { Stringified } from '@penumbra-zone/types/jsonified'; +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; + +export interface NumerairesSlice { + selectedNumeraires: Stringified[]; + selectNumeraire: (numeraire: Stringified) => void; + saveNumeraires: () => Promise; +} + +export const createNumerairesSlice = + (local: ExtensionStorage): SliceCreator => + (set, get) => { + return { + selectedNumeraires: [], + selectNumeraire: (numeraire: Stringified) => { + set(state => { + const index = state.numeraires.selectedNumeraires.indexOf(numeraire); + if (index > -1) { + state.numeraires.selectedNumeraires.splice(index, 1); + } else { + state.numeraires.selectedNumeraires.push(numeraire); + } + }); + }, + saveNumeraires: async () => { + await local.set('numeraires', get().numeraires.selectedNumeraires); + }, + }; + }; + +export const numerairesSelector = (state: AllSlices) => state.numeraires; diff --git a/apps/extension/src/state/persist.ts b/apps/extension/src/state/persist.ts index 9cfb1793..7046d0b2 100644 --- a/apps/extension/src/state/persist.ts +++ b/apps/extension/src/state/persist.ts @@ -7,6 +7,7 @@ import { LocalStorageState } from '../storage/types'; import { sessionExtStorage, SessionStorageState } from '../storage/session'; import { StorageItem } from '../storage/base'; import { walletsFromJson } from '@penumbra-zone/types/wallet'; +import { AppParameters } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/app/v1/app_pb'; export type Middleware = < T, @@ -31,6 +32,7 @@ export const customPersistImpl: Persist = f => (set, get, store) => { const grpcEndpoint = await localExtStorage.get('grpcEndpoint'); const knownSites = await localExtStorage.get('knownSites'); const frontendUrl = await localExtStorage.get('frontendUrl'); + const numeraires = await localExtStorage.get('numeraires'); set( produce((state: AllSlices) => { @@ -39,6 +41,7 @@ export const customPersistImpl: Persist = f => (set, get, store) => { state.network.grpcEndpoint = grpcEndpoint; state.connectedSites.knownSites = knownSites; state.defaultFrontend.url = frontendUrl; + state.numeraires.selectedNumeraires = numeraires; }), ); @@ -107,6 +110,30 @@ function syncLocal(changes: Record, set: S }), ); } + + if (changes['numeraires']) { + const stored = changes['numeraires'].newValue as + | StorageItem + | undefined; + set( + produce((state: AllSlices) => { + state.numeraires.selectedNumeraires = stored?.value ?? state.numeraires.selectedNumeraires; + }), + ); + } + + if (changes['params']) { + const stored = changes['params'].newValue as + | StorageItem + | undefined; + set( + produce((state: AllSlices) => { + state.network.chainId = stored?.value + ? AppParameters.fromJsonString(stored.value).chainId + : state.network.chainId; + }), + ); + } } function syncSession(changes: Record, set: Setter) { diff --git a/apps/extension/src/storage/local.ts b/apps/extension/src/storage/local.ts index 59a2e93a..30fd3a4b 100644 --- a/apps/extension/src/storage/local.ts +++ b/apps/extension/src/storage/local.ts @@ -10,6 +10,7 @@ export const localDefaults: Required = { params: undefined, passwordKeyPrint: undefined, frontendUrl: undefined, + numeraires: [], }; // Meant to be used for long-term persisted data. It is cleared when the extension is removed. diff --git a/apps/extension/src/storage/types.ts b/apps/extension/src/storage/types.ts index fec1a9fe..1df3b80a 100644 --- a/apps/extension/src/storage/types.ts +++ b/apps/extension/src/storage/types.ts @@ -1,8 +1,9 @@ import { AppParameters } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/app/v1/app_pb'; import { KeyPrintJson } from '@penumbra-zone/crypto-web/encryption'; -import { Jsonified } from '@penumbra-zone/types/jsonified'; +import { Stringified } from '@penumbra-zone/types/jsonified'; import { UserChoice } from '@penumbra-zone/types/user-choice'; import { WalletJson } from '@penumbra-zone/types/wallet'; +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; export enum LocalStorageVersion { V1 = 'V1', @@ -22,5 +23,6 @@ export interface LocalStorageState { passwordKeyPrint: KeyPrintJson | undefined; fullSyncHeight: number | undefined; knownSites: OriginRecord[]; - params: Jsonified | undefined; + params: Stringified | undefined; + numeraires: Stringified[]; } diff --git a/apps/extension/src/utils/test-constants.ts b/apps/extension/src/utils/test-constants.ts index 94bbb33b..9f1e848a 100644 --- a/apps/extension/src/utils/test-constants.ts +++ b/apps/extension/src/utils/test-constants.ts @@ -11,4 +11,5 @@ export const localTestDefaults: LocalStorageState = { grpcEndpoint: undefined, passwordKeyPrint: undefined, frontendUrl: EXAMPLE_MINIFRONT_URL, + numeraires: [], }; diff --git a/apps/extension/src/wallet-services.ts b/apps/extension/src/wallet-services.ts index 58804302..606ea7df 100644 --- a/apps/extension/src/wallet-services.ts +++ b/apps/extension/src/wallet-services.ts @@ -11,10 +11,12 @@ import { onboardGrpcEndpoint, onboardWallet } from './storage/onboard'; import { Services } from '@penumbra-zone/services-context'; import { ServicesMessage } from './message/services'; import { WalletServices } from '@penumbra-zone/types/services'; +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; export const startWalletServices = async () => { const wallet = await onboardWallet(); const grpcEndpoint = await onboardGrpcEndpoint(); + const numeraires = await localExtStorage.get('numeraires'); const services = new Services({ grpcEndpoint, @@ -22,6 +24,7 @@ export const startWalletServices = async () => { idbVersion: IDB_VERSION, walletId: WalletId.fromJsonString(wallet.id), fullViewingKey: FullViewingKey.fromJsonString(wallet.fullViewingKey), + numeraires: numeraires.map(n => AssetId.fromJsonString(n)), }); const { blockProcessor, indexedDb } = await services.getWalletServices(); @@ -38,7 +41,7 @@ export const startWalletServices = async () => { const getChainId = async (baseUrl: string) => { const localChainId = await localExtStorage .get('params') - .then(json => json && AppParameters.fromJson(json).chainId); + .then(json => json && AppParameters.fromJsonString(json).chainId); if (localChainId) return localChainId; @@ -89,5 +92,17 @@ const attachServiceControlListener = ({ .then(() => respond()) .finally(() => chrome.runtime.reload()); return true; + case ServicesMessage.ChangeNumeraires: + void (async () => { + const newNumeraires = await localExtStorage.get('numeraires'); + blockProcessor.setNumeraires(newNumeraires.map(n => AssetId.fromJsonString(n))); + /** + * Changing numeraires causes all BSOD-based prices to be removed. + * This means that some new blocks will need to be scanned to get prices for the new numeraires. + * It also means that immediately after changing numeraires user will not see any equivalent BSOD-based prices. + */ + await indexedDb.clearSwapBasedPrices(); + })().then(() => respond()); + return true; } }); diff --git a/packages/query/src/block-processor.ts b/packages/query/src/block-processor.ts index 84f3629b..5af75348 100644 --- a/packages/query/src/block-processor.ts +++ b/packages/query/src/block-processor.ts @@ -57,7 +57,7 @@ interface QueryClientProps { querier: RootQuerier; indexedDb: IndexedDbInterface; viewServer: ViewServerInterface; - numeraires: Metadata[]; + numeraires: AssetId[]; stakingTokenMetadata: Metadata; } @@ -76,7 +76,7 @@ export class BlockProcessor implements BlockProcessorInterface { private readonly indexedDb: IndexedDbInterface; private readonly viewServer: ViewServerInterface; private readonly abortController: AbortController = new AbortController(); - private readonly numeraires: Metadata[]; + private numeraires: AssetId[]; private readonly stakingTokenMetadata: Metadata; private syncPromise: Promise | undefined; @@ -116,6 +116,10 @@ export class BlockProcessor implements BlockProcessorInterface { public stop = (r: string) => this.abortController.abort(`Sync stop ${r}`); + setNumeraires(numeraires: AssetId[]): void { + this.numeraires = numeraires; + } + private async syncAndStore() { // start at next block, or genesis if height is undefined let currentHeight = (await this.indexedDb.getFullSyncHeight()) ?? -1n; diff --git a/packages/query/src/price-indexer.ts b/packages/query/src/price-indexer.ts index ad15dc58..0ce4f4d6 100644 --- a/packages/query/src/price-indexer.ts +++ b/packages/query/src/price-indexer.ts @@ -1,10 +1,7 @@ import { BatchSwapOutputData } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb'; import { IndexedDbInterface } from '@penumbra-zone/types/indexed-db'; import { divideAmounts, isZero, subtractAmounts } from '@penumbra-zone/types/amount'; -import { - AssetId, - Metadata, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +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, @@ -16,7 +13,6 @@ import { getUnfilled1Amount, getUnfilled2Amount, } from '@penumbra-zone/getters/batch-swap-output-data'; -import { getAssetId } from '@penumbra-zone/getters/metadata'; /** * @@ -41,12 +37,11 @@ export const calculatePrice = (delta: Amount, unfilled: Amount, lambda: Amount): export const updatePricesFromSwaps = async ( indexedDb: IndexedDbInterface, - numeraires: Metadata[], + numeraires: AssetId[], swapOutputs: BatchSwapOutputData[], height: bigint, ) => { - for (const numeraireMetadata of numeraires) { - const numeraireAssetId = getAssetId(numeraireMetadata); + for (const numeraireAssetId of numeraires) { await deriveAndSavePriceFromBSOD(indexedDb, numeraireAssetId, swapOutputs, height); } }; diff --git a/packages/services-context/src/index.ts b/packages/services-context/src/index.ts index 95a860ed..48b2e62a 100644 --- a/packages/services-context/src/index.ts +++ b/packages/services-context/src/index.ts @@ -9,6 +9,7 @@ import { } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb'; import { ChainRegistryClient } from '@penumbra-labs/registry'; import { AppParameters } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/app/v1/app_pb'; +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; export interface ServicesConfig { readonly chainId: string; @@ -16,6 +17,7 @@ export interface ServicesConfig { readonly grpcEndpoint: string; readonly walletId: WalletId; readonly fullViewingKey: FullViewingKey; + readonly numeraires: AssetId[]; } export class Services implements ServicesInterface { @@ -82,7 +84,7 @@ export class Services implements ServicesInterface { } private async initializeWalletServices(): Promise { - const { chainId, grpcEndpoint, walletId, fullViewingKey, idbVersion } = this.config; + const { chainId, grpcEndpoint, walletId, fullViewingKey, idbVersion, numeraires } = this.config; const querier = new RootQuerier({ grpcEndpoint }); const registryClient = new ChainRegistryClient(); const indexedDb = await IndexedDb.initialize({ @@ -109,7 +111,7 @@ export class Services implements ServicesInterface { querier, indexedDb, stakingTokenMetadata: registry.getMetadata(registry.stakingAssetId), - numeraires: registry.numeraires.map(numeraires => registry.getMetadata(numeraires)), + numeraires: numeraires, }); return { viewServer, blockProcessor, indexedDb, querier }; diff --git a/packages/storage/src/indexed-db/index.ts b/packages/storage/src/indexed-db/index.ts index 061dfb32..ea21ac78 100644 --- a/packages/storage/src/indexed-db/index.ts +++ b/packages/storage/src/indexed-db/index.ts @@ -79,6 +79,7 @@ export class IndexedDb implements IndexedDbInterface { private readonly u: IbdUpdater, private readonly c: IdbConstants, private readonly chainId: string, + private readonly stakingTokenAssetId: AssetId, ) {} static async initialize({ @@ -134,7 +135,13 @@ export class IndexedDb implements IndexedDbInterface { tables: IDB_TABLES, } satisfies IdbConstants; - const instance = new this(db, new IbdUpdater(db), constants, chainId); + const instance = new this( + db, + new IbdUpdater(db), + constants, + chainId, + registryClient.get(chainId).stakingAssetId, + ); await instance.saveRegistryAssets(registryClient, chainId); // Pre-load asset metadata from registry const existing0thEpoch = await instance.getEpochByHeight(0n); @@ -661,6 +668,21 @@ export class IndexedDb implements IndexedDbInterface { .filter(price => price.asOfHeight >= minHeight); } + async clearSwapBasedPrices(): Promise { + const tx = this.db.transaction('PRICES', 'readwrite'); + const store = tx.objectStore('PRICES'); + + let cursor = await store.openCursor(); + while (cursor) { + const price = EstimatedPrice.fromJson(cursor.value); + if (!price.numeraire?.equals(this.stakingTokenAssetId)) { + await cursor.delete(); + } + cursor = await cursor.continue(); + } + await tx.done; + } + private determinePriceRelevanceThresholdForAsset(assetMetadata: Metadata): number { if (assetPatterns.delegationToken.capture(assetMetadata.display)) { return PRICE_RELEVANCE_THRESHOLDS.delegationToken; diff --git a/packages/storage/src/indexed-db/indexed-db.test.ts b/packages/storage/src/indexed-db/indexed-db.test.ts index 79b19a20..47f57c35 100644 --- a/packages/storage/src/indexed-db/indexed-db.test.ts +++ b/packages/storage/src/indexed-db/indexed-db.test.ts @@ -594,9 +594,12 @@ describe('IndexedDb', () => { const numeraireAssetId = new AssetId({ inner: new Uint8Array([5, 6, 7, 8]) }); + const stakingAssetId = AssetId.fromJson({ + inner: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=', + }); beforeEach(async () => { db = await IndexedDb.initialize({ ...generateInitialProps() }); - await db.updatePrice(delegationMetadataA.penumbraAssetId!, numeraireAssetId, 1.23, 50n); + await db.updatePrice(delegationMetadataA.penumbraAssetId!, stakingAssetId, 1.23, 50n); await db.updatePrice(metadataA.penumbraAssetId!, numeraireAssetId, 22.15, 40n); }); @@ -606,7 +609,7 @@ describe('IndexedDb', () => { await expect(db.getPricesForAsset(delegationMetadataA, 50n)).resolves.toEqual([ new EstimatedPrice({ pricedAsset: delegationMetadataA.penumbraAssetId!, - numeraire: numeraireAssetId, + numeraire: stakingAssetId, numerairePerUnit: 1.23, asOfHeight: 50n, }), @@ -622,12 +625,17 @@ describe('IndexedDb', () => { await expect(db.getPricesForAsset(delegationMetadataA, 241n)).resolves.toEqual([ new EstimatedPrice({ pricedAsset: delegationMetadataA.penumbraAssetId!, - numeraire: numeraireAssetId, + numeraire: stakingAssetId, numerairePerUnit: 1.23, asOfHeight: 50n, }), ]); }); + + it('should delete only prices with a numeraires different from the staking token', async () => { + await db.clearSwapBasedPrices(); + await expect(db.getPricesForAsset(metadataA, 50n)).resolves.toEqual([]); + }); }); describe('upsertAuction()', () => { diff --git a/packages/types/src/block-processor.ts b/packages/types/src/block-processor.ts index 399769dd..8288a9fa 100644 --- a/packages/types/src/block-processor.ts +++ b/packages/types/src/block-processor.ts @@ -1,4 +1,7 @@ +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; + export interface BlockProcessorInterface { sync(): Promise; stop(r?: string): void; + setNumeraires(numeraires: AssetId[]): void; } diff --git a/packages/types/src/indexed-db.ts b/packages/types/src/indexed-db.ts index ec0b2e21..e4bef3bb 100644 --- a/packages/types/src/indexed-db.ts +++ b/packages/types/src/indexed-db.ts @@ -110,6 +110,7 @@ export interface IndexedDbInterface { height: bigint, ): Promise; getPricesForAsset(assetMetadata: Metadata, latestBlockHeight: bigint): Promise; + clearSwapBasedPrices(): Promise; // Add more auction union types as they are created upsertAuction( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ebd1f8a..76df5480 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: '@penumbra-zone/crypto-web': specifier: workspace:* version: link:../../packages/crypto + '@penumbra-zone/getters': + specifier: workspace:* + version: link:../../packages/getters '@penumbra-zone/perspective': specifier: workspace:* version: link:../../packages/perspective