diff --git a/.changeset/smart-apples-explode.md b/.changeset/smart-apples-explode.md new file mode 100644 index 0000000000..baa0f140af --- /dev/null +++ b/.changeset/smart-apples-explode.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/services': minor +--- + +Add priority scores to the asset metadata diff --git a/.changeset/tough-lemons-brush.md b/.changeset/tough-lemons-brush.md new file mode 100644 index 0000000000..75a3cb4eb7 --- /dev/null +++ b/.changeset/tough-lemons-brush.md @@ -0,0 +1,5 @@ +--- +'minifront': patch +--- + +Add priority sorting to the assets table diff --git a/apps/minifront/src/components/dashboard/assets-table/index.tsx b/apps/minifront/src/components/dashboard/assets-table/index.tsx index 85a2e41b04..f12adecc05 100644 --- a/apps/minifront/src/components/dashboard/assets-table/index.tsx +++ b/apps/minifront/src/components/dashboard/assets-table/index.tsx @@ -12,12 +12,14 @@ import { import { ValueViewComponent } from '@repo/ui/components/ui/value'; import { EquivalentValues } from './equivalent-values'; import { Fragment } from 'react'; -import { shouldDisplay } from './helpers'; import { PagePath } from '../../metadata/paths'; import { Link } from 'react-router-dom'; import { getMetadataFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response'; import { getAddressIndex } from '@penumbra-zone/getters/address-view'; -import { balancesByAccountSelector, useBalancesResponses } from '../../../state/shared'; +import { BalancesByAccount, groupByAccount, useBalancesResponses } from '../../../state/shared'; +import { AbridgedZQueryState } from '@penumbra-zone/zquery/src/types'; +import { shouldDisplay } from '../../../fetchers/balances/should-display'; +import { sortByPriorityScore } from '../../../fetchers/balances/by-priority-score'; const getTradeLink = (balance: BalancesResponse): string => { const metadata = getMetadataFromBalancesResponseOptional(balance); @@ -26,9 +28,15 @@ const getTradeLink = (balance: BalancesResponse): string => { return metadata ? `${PagePath.SWAP}?from=${metadata.symbol}${accountQuery}` : PagePath.SWAP; }; +const filteredBalancesByAccountSelector = ( + zQueryState: AbridgedZQueryState, +): BalancesByAccount[] => + zQueryState.data?.filter(shouldDisplay).sort(sortByPriorityScore).reduce(groupByAccount, []) ?? + []; + export default function AssetsTable() { const balancesByAccount = useBalancesResponses({ - select: balancesByAccountSelector, + select: filteredBalancesByAccountSelector, shouldReselect: (before, after) => before?.data !== after.data, }); @@ -75,7 +83,7 @@ export default function AssetsTable() { - {account.balances.filter(shouldDisplay).map((assetBalance, index) => ( + {account.balances.map((assetBalance, index) => ( diff --git a/apps/minifront/src/fetchers/balances/by-account.ts b/apps/minifront/src/fetchers/balances/by-account.ts deleted file mode 100644 index 3e2b8bb8b7..0000000000 --- a/apps/minifront/src/fetchers/balances/by-account.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb'; -import { getBalances } from '.'; -import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; -import { getAddress, getAddressIndex } from '@penumbra-zone/getters/address-view'; - -export interface BalancesByAccount { - account: number; - address: Address; - balances: BalancesResponse[]; -} - -const groupByAccount = (acc: BalancesByAccount[], curr: BalancesResponse): BalancesByAccount[] => { - const index = getAddressIndex(curr.accountAddress); - const grouping = acc.find(a => a.account === index.account); - - if (grouping) { - grouping.balances.push(curr); - } else { - acc.push({ - account: index.account, - address: getAddress(curr.accountAddress), - balances: [curr], - }); - } - - return acc; -}; - -export const getBalancesByAccount = async (): Promise => { - const balances = await getBalances(); - return balances.reduce(groupByAccount, []); -}; diff --git a/apps/minifront/src/fetchers/balances/by-priority-score.ts b/apps/minifront/src/fetchers/balances/by-priority-score.ts new file mode 100644 index 0000000000..86affdada9 --- /dev/null +++ b/apps/minifront/src/fetchers/balances/by-priority-score.ts @@ -0,0 +1,23 @@ +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { + getMetadataFromBalancesResponseOptional, + getAmount, +} from '@penumbra-zone/getters/balances-response'; +import { multiplyAmountByNumber, joinLoHiAmount } from '@penumbra-zone/types/amount'; + +export const sortByPriorityScore = (a: BalancesResponse, b: BalancesResponse) => { + const aScore = getMetadataFromBalancesResponseOptional(a)?.priorityScore ?? 1n; + const bScore = getMetadataFromBalancesResponseOptional(b)?.priorityScore ?? 1n; + + const aAmount = getAmount.optional()(a); + const bAmount = getAmount.optional()(b); + + const aPriority = aAmount + ? joinLoHiAmount(multiplyAmountByNumber(aAmount, Number(aScore))) + : aScore; + const bPriority = bAmount + ? joinLoHiAmount(multiplyAmountByNumber(bAmount, Number(bScore))) + : bScore; + + return Number(bPriority - aPriority); +}; diff --git a/apps/minifront/src/components/dashboard/assets-table/helpers.ts b/apps/minifront/src/fetchers/balances/should-display.ts similarity index 93% rename from apps/minifront/src/components/dashboard/assets-table/helpers.ts rename to apps/minifront/src/fetchers/balances/should-display.ts index fb421b636c..874213dda6 100644 --- a/apps/minifront/src/components/dashboard/assets-table/helpers.ts +++ b/apps/minifront/src/fetchers/balances/should-display.ts @@ -2,7 +2,7 @@ import { assetPatterns } from '@penumbra-zone/types/assets'; import { getDisplay } from '@penumbra-zone/getters/metadata'; import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { getMetadata } from '@penumbra-zone/getters/value-view'; -import { isKnown } from '../../../state/helpers'; +import { isKnown } from '../../state/helpers'; // We don't have to disclose auctionNft to the user since it is a kind of utility asset needed only // for the implementation of the Dutch auction diff --git a/apps/minifront/src/fetchers/registry.ts b/apps/minifront/src/fetchers/registry.ts index c7278009c4..b790c2966a 100644 --- a/apps/minifront/src/fetchers/registry.ts +++ b/apps/minifront/src/fetchers/registry.ts @@ -36,7 +36,6 @@ export const getChains = async (): Promise => { const chainId = await getChainId(); if (!chainId) throw new Error('Could not fetch chain id'); - const registryClient = new ChainRegistryClient(); - const { ibcConnections } = registryClient.get(chainId); + const { ibcConnections } = chainRegistryClient.get(chainId); return ibcConnections; }; diff --git a/apps/minifront/src/state/shared.ts b/apps/minifront/src/state/shared.ts index 94ac905af2..5b092df225 100644 --- a/apps/minifront/src/state/shared.ts +++ b/apps/minifront/src/state/shared.ts @@ -66,7 +66,10 @@ export interface BalancesByAccount { balances: BalancesResponse[]; } -const groupByAccount = (acc: BalancesByAccount[], curr: BalancesResponse): BalancesByAccount[] => { +export const groupByAccount = ( + acc: BalancesByAccount[], + curr: BalancesResponse, +): BalancesByAccount[] => { const index = getAddressIndex(curr.accountAddress); const grouping = acc.find(a => a.account === index.account); diff --git a/packages/services/src/view-service/asset-metadata-by-id.ts b/packages/services/src/view-service/asset-metadata-by-id.ts index 32405825ae..93d3225e4b 100644 --- a/packages/services/src/view-service/asset-metadata-by-id.ts +++ b/packages/services/src/view-service/asset-metadata-by-id.ts @@ -1,6 +1,7 @@ import type { Impl } from '.'; import { servicesCtx } from '../ctx/prax'; import { assetPatterns } from '@penumbra-zone/types/assets'; +import { getAssetPriorityScore } from './util/asset-priority-score'; export const assetMetadataById: Impl['assetMetadataById'] = async ({ assetId }, ctx) => { if (!assetId) throw new Error('No asset id passed in request'); @@ -14,9 +15,23 @@ export const assetMetadataById: Impl['assetMetadataById'] = async ({ assetId }, const { indexedDb, querier } = await services.getWalletServices(); const localMetadata = await indexedDb.getAssetsMetadata(assetId); - if (localMetadata) return { denomMetadata: localMetadata }; + if (localMetadata) { + if (!localMetadata.priorityScore) { + localMetadata.priorityScore = getAssetPriorityScore( + localMetadata, + indexedDb.stakingTokenAssetId, + ); + } + return { denomMetadata: localMetadata }; + } const remoteMetadata = await querier.shieldedPool.assetMetadataById(assetId); + if (remoteMetadata && !remoteMetadata.priorityScore) { + remoteMetadata.priorityScore = getAssetPriorityScore( + remoteMetadata, + indexedDb.stakingTokenAssetId, + ); + } const isIbcAsset = remoteMetadata && assetPatterns.ibc.matches(remoteMetadata.display); diff --git a/packages/services/src/view-service/balances.test.ts b/packages/services/src/view-service/balances.test.ts index 8c24aaac09..a10cf192f3 100644 --- a/packages/services/src/view-service/balances.test.ts +++ b/packages/services/src/view-service/balances.test.ts @@ -197,7 +197,7 @@ describe('Balances request handler', () => { expect(getEquivalentValues(response.balanceView)).toEqual([ new EquivalentValue({ asOfHeight: 123n, - numeraire: { penumbraAssetId: numeraire }, + numeraire: { penumbraAssetId: numeraire, priorityScore: 40n }, equivalentAmount, }), ]); diff --git a/packages/services/src/view-service/util/asset-priority-score.test.ts b/packages/services/src/view-service/util/asset-priority-score.test.ts new file mode 100644 index 0000000000..23c7ac6728 --- /dev/null +++ b/packages/services/src/view-service/util/asset-priority-score.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { getAssetPriorityScore } from './asset-priority-score'; +import { + AssetId, + Metadata, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { base64ToUint8Array } from '@penumbra-zone/types/base64'; + +describe('getAssetPriorityScore', () => { + const umTokenId = 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA='; + const umToken = new AssetId({ inner: base64ToUint8Array(umTokenId) }); + const delegationTokenId = '/5AHh95RAybBbUhQ5zXMWCvstH4rRK/5KMVIVGQltAw='; + const usdcTokenId = 'A/8PdbaWqFds9NiYzmAN75SehGpkLwr7tgoVmwaIVgg='; + + it('returns 0 for the undefined metadata', () => { + expect(getAssetPriorityScore(undefined, umToken)).toBe(0n); + }); + + it('returns 10 for an unbonding token', () => { + const metadata = new Metadata({ + display: 'unbonding_start_at_100_penumbravalid1abc123', + }); + + expect(getAssetPriorityScore(metadata, umToken)).toBe(10n); + }); + + it('returns 20 for a delegation token', () => { + const metadata = new Metadata({ + display: + 'delegation_penumbravalid1sqwq8p8fqxx4aflthtwmu6kte8je7sh4tj7pyd82qpvdap5ajgrsv0q0ja', + penumbraAssetId: { + inner: base64ToUint8Array(delegationTokenId), + }, + }); + + expect(getAssetPriorityScore(metadata, umToken)).toBe(20n); + }); + + it('returns 30 for an auction token', () => { + const metadata = new Metadata({ + display: 'auctionnft_0_pauctid1abc123', + }); + + expect(getAssetPriorityScore(metadata, umToken)).toBe(30n); + }); + + it('returns 40 for an token within registry', () => { + const metadata = new Metadata({ + display: 'transfer/channel-7/usdc', + penumbraAssetId: { + inner: base64ToUint8Array(usdcTokenId), + }, + }); + + expect(getAssetPriorityScore(metadata, umToken)).toBe(40n); + }); + + it('returns 50 for the UM token', () => { + const metadata = new Metadata({ + penumbraAssetId: { + inner: base64ToUint8Array(umTokenId), + }, + }); + + expect(getAssetPriorityScore(metadata, umToken)).toBe(50n); + }); +}); diff --git a/packages/services/src/view-service/util/asset-priority-score.ts b/packages/services/src/view-service/util/asset-priority-score.ts new file mode 100644 index 0000000000..e6fce22f13 --- /dev/null +++ b/packages/services/src/view-service/util/asset-priority-score.ts @@ -0,0 +1,50 @@ +import { assetPatterns } from '@penumbra-zone/types/assets'; +import { + AssetId, + Metadata, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; + +/** + * Assigns a priority score to an asset based on its metadata. + * The higher the score, the earlier the asset will be displayed in the UI. + * - UM → 50 da + * - Normal and IBC denoms → 40 + * - Auctions → 30 + * - Delegations → 20 + * - Unbondings, proposals, voting receipts → 10 + * - Unknown → 0 + * + * If a user has the balance of the asset, then the balance amount should be multiplied by this score in a sorting function. + * + * @param metadata {Metadata} – Asset metadata to assign the priority score + * @param nativeTokenId {AssetId} – AssetId of the native chain token (UM) + */ +export const getAssetPriorityScore = ( + metadata: Metadata | undefined, + nativeTokenId: AssetId, +): bigint => { + if (!metadata) return 0n; + + if (metadata.penumbraAssetId?.equals(nativeTokenId)) { + return 50n; + } + + if (assetPatterns.ibc.matches(metadata.display)) return 40n; + + if ( + assetPatterns.auctionNft.matches(metadata.display) || + assetPatterns.lpNft.matches(metadata.display) + ) + return 30n; + + if (assetPatterns.delegationToken.matches(metadata.display)) return 20n; + + if ( + assetPatterns.unbondingToken.matches(metadata.display) || + assetPatterns.proposalNft.matches(metadata.display) || + assetPatterns.votingReceipt.matches(metadata.display) + ) + return 10n; + + return 40n; +};