diff --git a/apps/webapp/src/components/dashboard/assets-table.tsx b/apps/webapp/src/components/dashboard/assets-table.tsx index c7e0fa92c6..a1e83fb9a2 100644 --- a/apps/webapp/src/components/dashboard/assets-table.tsx +++ b/apps/webapp/src/components/dashboard/assets-table.tsx @@ -1,5 +1,5 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@penumbra-zone/ui'; -import { displayUsd, fromBaseUnitAmount } from '@penumbra-zone/types'; +import { displayUsd, fromBaseUnitAmountAndDenomMetadata } from '@penumbra-zone/types'; import { LoaderFunction, useLoaderData } from 'react-router-dom'; import { throwIfExtNotInstalled } from '../../fetchers/is-connected.ts'; import { AccountBalance, getBalancesByAccount } from '../../fetchers/balances.ts'; @@ -48,11 +48,14 @@ export default function AssetsTable() { {a.balances.map((asset, i) => (
- -

{asset.denom.display}

+ +

{asset.denomMetadata.display}

- {fromBaseUnitAmount(asset.amount, asset.denom.exponent).toFormat()} + {fromBaseUnitAmountAndDenomMetadata( + asset.amount, + asset.denomMetadata, + ).toFormat()}

{asset.usdcValue == 0 ? '$–' : `$${displayUsd(asset.usdcValue)}`} @@ -73,14 +76,19 @@ export default function AssetsTable() {

- -

{asset.denom.display}

+ +

+ {asset.denomMetadata.display} +

- {fromBaseUnitAmount(asset.amount, asset.denom.exponent).toFormat()} + {fromBaseUnitAmountAndDenomMetadata( + asset.amount, + asset.denomMetadata, + ).toFormat()}

diff --git a/apps/webapp/src/components/shared/input-token.tsx b/apps/webapp/src/components/shared/input-token.tsx index 9c3be671d4..7ebe7b61ab 100644 --- a/apps/webapp/src/components/shared/input-token.tsx +++ b/apps/webapp/src/components/shared/input-token.tsx @@ -1,11 +1,13 @@ import { Input, InputProps } from '@penumbra-zone/ui'; import { cn } from '@penumbra-zone/ui/lib/utils'; -import { fromBaseUnitAmount, joinLoHiAmount } from '@penumbra-zone/types'; +import { joinLoHiAmount } from '@penumbra-zone/types'; import SelectTokenModal from './select-token-modal'; import { Validation, validationResult } from './validation-result'; import { AccountBalance, AssetBalance } from '../../fetchers/balances'; import { Selection } from '../../state/types'; import { Fee } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1alpha1/fee_pb'; +import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value'; +import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; const PENUMBRA_FEE_DENOMINATOR = 1000; @@ -14,10 +16,23 @@ const getFeeAsString = (fee: Fee | undefined) => { return `${(Number(joinLoHiAmount(fee.amount)) / PENUMBRA_FEE_DENOMINATOR).toString()} penumbra`; }; -const getCurrentBalance = (assetBalance: AssetBalance | undefined) => - assetBalance - ? fromBaseUnitAmount(assetBalance.amount, assetBalance.denom.exponent).toFormat() - : '0'; +const getCurrentBalanceValueView = (assetBalance: AssetBalance | undefined): ValueView => { + if (assetBalance?.denomMetadata) + return new ValueView({ + valueView: { + case: 'knownDenom', + value: { amount: assetBalance.amount, denom: assetBalance.denomMetadata }, + }, + }); + else if (assetBalance?.assetId) + return new ValueView({ + valueView: { + case: 'unknownDenom', + value: { amount: assetBalance.amount, assetId: assetBalance.assetId }, + }, + }); + else return new ValueView(); +}; interface InputTokenProps extends InputProps { label: string; @@ -47,7 +62,7 @@ export default function InputToken({ }: InputTokenProps) { const vResult = validationResult(value, validations); - const currentBalance = getCurrentBalance(selection?.asset); + const currentBalanceValueView = getCurrentBalanceValueView(selection?.asset); const feeAsString = getFeeAsString(fee); return ( @@ -93,7 +108,7 @@ export default function InputToken({
Wallet -

{currentBalance}

+
diff --git a/apps/webapp/src/components/shared/select-token-modal.tsx b/apps/webapp/src/components/shared/select-token-modal.tsx index 3900b9b48e..b1f6b686be 100644 --- a/apps/webapp/src/components/shared/select-token-modal.tsx +++ b/apps/webapp/src/components/shared/select-token-modal.tsx @@ -8,7 +8,7 @@ import { DialogTrigger, Input, } from '@penumbra-zone/ui'; -import { fromBaseUnitAmount } from '@penumbra-zone/types'; +import { fromBaseUnitAmountAndDenomMetadata } from '@penumbra-zone/types'; import { cn } from '@penumbra-zone/ui/lib/utils'; import { AccountBalance } from '../../fetchers/balances'; import { AssetIcon } from './asset-icon'; @@ -31,9 +31,11 @@ export default function SelectTokenModal({
- {selection?.asset?.denom.display && } + {selection?.asset?.denomMetadata.display && ( + + )}

- {selection?.asset?.denom.display} + {selection?.asset?.denomMetadata.display}

@@ -75,11 +77,11 @@ export default function SelectTokenModal({ >

{b.index}

- -

{k.denom.display}

+ +

{k.denomMetadata.display}

- {fromBaseUnitAmount(k.amount, k.denom.exponent).toFormat()} + {fromBaseUnitAmountAndDenomMetadata(k.amount, k.denomMetadata).toFormat()}

diff --git a/apps/webapp/src/components/swap/asset-out-box.tsx b/apps/webapp/src/components/swap/asset-out-box.tsx index 0ea9ff5bdd..19e15105d8 100644 --- a/apps/webapp/src/components/swap/asset-out-box.tsx +++ b/apps/webapp/src/components/swap/asset-out-box.tsx @@ -29,8 +29,8 @@ export const AssetOutBox = ({ balances }: AssetOutBoxProps) => { value: { amount: balanceOfDenom.amount, denom: new DenomMetadata({ - display: balanceOfDenom.denom.display, - denomUnits: [balanceOfDenom.denom], + display: balanceOfDenom.denomMetadata.display, + denomUnits: balanceOfDenom.denomMetadata.denomUnits, }), }, }, diff --git a/apps/webapp/src/fetchers/balances.ts b/apps/webapp/src/fetchers/balances.ts index 13b80d8644..7307e3abee 100644 --- a/apps/webapp/src/fetchers/balances.ts +++ b/apps/webapp/src/fetchers/balances.ts @@ -5,7 +5,7 @@ import { } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1alpha1/view_pb'; import { AssetId, - DenomUnit, + DenomMetadata, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; import { getAddresses, IndexAddrRecord } from './address'; import { getAllAssets } from './assets'; @@ -16,10 +16,7 @@ import { viewClient } from '../clients/grpc'; import { streamToPromise } from './stream'; export interface AssetBalance { - denom: { - display: DenomUnit['denom']; - exponent: DenomUnit['exponent']; - }; + denomMetadata: DenomMetadata; assetId: AssetId; amount: Amount; usdcValue: number; @@ -35,29 +32,19 @@ type NormalizedBalance = AssetBalance & { account: { index: number; address: string }; }; -// Given an asset has many denom units, the amount should be formatted using -// the exponent of the display denom (e.g. 1,954,000,000 upenumbra = 1,954 penumbra) -export const displayDenom = (res?: AssetsResponse): { display: string; exponent: number } => { - const display = res?.denomMetadata?.display; - if (!display) return { display: 'unknown', exponent: 0 }; - - const match = res.denomMetadata?.denomUnits.find(d => d.denom === display); - if (!match) return { display, exponent: 0 }; - - return { display, exponent: match.exponent }; -}; - -const getDenomAmount = (res: BalancesResponse, metadata: AssetsResponse[]) => { +const getDenomAmount = ( + res: BalancesResponse, + metadata: AssetsResponse[], +): { amount: Amount; denomMetadata: DenomMetadata } => { const assetId = uint8ArrayToBase64(res.balance!.assetId!.inner); const match = metadata.find(m => { if (!m.denomMetadata?.penumbraAssetId?.inner) return false; return assetId === uint8ArrayToBase64(m.denomMetadata.penumbraAssetId.inner); }); - const { display, exponent } = displayDenom(match); const amount = res.balance?.amount ?? new Amount(); - return { display, exponent, amount }; + return { amount, denomMetadata: match?.denomMetadata ?? new DenomMetadata() }; }; const normalize = @@ -66,10 +53,10 @@ const normalize = const index = res.account?.account ?? 0; const address = indexAddrRecord?.[index] ?? ''; - const { display, exponent, amount } = getDenomAmount(res, metadata); + const { denomMetadata, amount } = getDenomAmount(res, metadata); return { - denom: { display, exponent }, + denomMetadata, assetId: res.balance!.assetId!, amount, //usdcValue: amount * 0.93245, // TODO: Temporary until pricing implemented @@ -81,7 +68,7 @@ const normalize = const groupByAccount = (balances: AccountBalance[], curr: NormalizedBalance): AccountBalance[] => { const match = balances.find(b => b.index === curr.account.index); const newBalance = { - denom: curr.denom, + denomMetadata: curr.denomMetadata, amount: curr.amount, usdcValue: curr.usdcValue, assetId: curr.assetId, @@ -109,7 +96,7 @@ const sortByAmount = (a: AssetBalance, b: AssetBalance): number => { return Number(joinLoHiAmount(b.amount) - joinLoHiAmount(a.amount)); // If both are equal, sort by asset name in ascending order - return a.denom.display.localeCompare(b.denom.display); + return a.denomMetadata.display.localeCompare(b.denomMetadata.display); }; // Sort by account (lowest first) diff --git a/apps/webapp/src/state/ibc.test.ts b/apps/webapp/src/state/ibc.test.ts index 84f05ef968..a24c40df45 100644 --- a/apps/webapp/src/state/ibc.test.ts +++ b/apps/webapp/src/state/ibc.test.ts @@ -2,9 +2,13 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { create, StoreApi, UseBoundStore } from 'zustand'; import { AllSlices, initializeStore } from './index.ts'; import { Chain } from '@penumbra-zone/types'; -import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { + AssetId, + DenomMetadata, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1alpha1/num_pb'; import { sendValidationErrors } from './send.ts'; +import { Selection } from './types.ts'; // TODO: Revisit tests when re-implementing ibc form @@ -15,14 +19,14 @@ describe.skip('IBC Slice', () => { lo: 0n, hi: 0n, }), - denom: { display: 'test_usd', exponent: 18 }, + denomMetadata: new DenomMetadata({ display: 'test_usd', denomUnits: [{ exponent: 18 }] }), usdcValue: 0, assetId: new AssetId().fromJson({ inner: 'reum7wQmk/owgvGMWMZn/6RFPV24zIKq3W6In/WwZgg=' }), }, address: 'penumbra1e8k5c3ds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uurrgkvtjpny3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd1', accountIndex: 0, - }; + } satisfies Selection; let useStore: UseBoundStore>; beforeEach(() => { diff --git a/apps/webapp/src/state/ibc.ts b/apps/webapp/src/state/ibc.ts index 8ae91abd5e..48a441215d 100644 --- a/apps/webapp/src/state/ibc.ts +++ b/apps/webapp/src/state/ibc.ts @@ -11,6 +11,7 @@ import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/c import { Selection } from './types'; import { ibcClient, viewClient } from '../clients/grpc'; import { planWitnessBuildBroadcast } from './helpers'; +import { getDisplayDenomExponent } from '@penumbra-zone/types/src/denom-metadata'; export interface IbcSendSlice { selection: Selection | undefined; @@ -130,8 +131,11 @@ const getPlanRequest = async ({ return new TransactionPlannerRequest({ ics20Withdrawals: [ { - amount: toBaseUnit(BigNumber(amount), selection.asset.denom.exponent), - denom: { denom: selection.asset.denom.display }, + amount: toBaseUnit( + BigNumber(amount), + getDisplayDenomExponent(selection.asset.denomMetadata), + ), + denom: { denom: selection.asset.denomMetadata.display }, destinationChainAddress, returnAddress, timeoutHeight, diff --git a/apps/webapp/src/state/send.test.ts b/apps/webapp/src/state/send.test.ts index 88d72d14d6..af93e4c680 100644 --- a/apps/webapp/src/state/send.test.ts +++ b/apps/webapp/src/state/send.test.ts @@ -3,13 +3,17 @@ import { create, StoreApi, UseBoundStore } from 'zustand'; import { AllSlices, initializeStore } from './index.ts'; import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1alpha1/num_pb'; import { sendValidationErrors } from './send.ts'; -import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { + AssetId, + DenomMetadata, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; import { Fee } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1alpha1/fee_pb'; import { viewClient } from '../clients/grpc.ts'; import { AddressByIndexResponse, TransactionPlannerResponse, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1alpha1/view_pb'; +import { Selection } from './types.ts'; vi.mock('../fetchers/address', () => ({ getAddressByIndex: vi.fn(), @@ -22,14 +26,14 @@ describe('Send Slice', () => { lo: 0n, hi: 0n, }), - denom: { display: 'test_usd', exponent: 18 }, + denomMetadata: new DenomMetadata({ display: 'test_usd', denomUnits: [{ exponent: 18 }] }), usdcValue: 0, assetId: new AssetId().fromJson({ inner: 'reum7wQmk/owgvGMWMZn/6RFPV24zIKq3W6In/WwZgg=' }), }, address: 'penumbra1e8k5c3ds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uurrgkvtjpny3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd1', accountIndex: 0, - }; + } satisfies Selection; let useStore: UseBoundStore>; diff --git a/apps/webapp/src/state/send.ts b/apps/webapp/src/state/send.ts index b914b770ae..e963db0c33 100644 --- a/apps/webapp/src/state/send.ts +++ b/apps/webapp/src/state/send.ts @@ -1,5 +1,9 @@ import { AllSlices, SliceCreator } from './index'; -import { fromBaseUnitAmount, isPenumbraAddr, toBaseUnit } from '@penumbra-zone/types'; +import { + fromBaseUnitAmountAndDenomMetadata, + isPenumbraAddr, + toBaseUnit, +} from '@penumbra-zone/types'; import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1alpha1/view_pb'; import { toast } from '@penumbra-zone/ui/components/ui/use-toast'; import BigNumber from 'bignumber.js'; @@ -11,6 +15,7 @@ import { MemoPlaintext } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/ import { getAddressByIndex } from '../fetchers/address'; import { getTransactionPlan, planWitnessBuildBroadcast } from './helpers.ts'; import { Fee } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1alpha1/fee_pb'; +import { getDisplayDenomExponent } from '@penumbra-zone/types/src/denom-metadata.ts'; export interface SendSlice { selection: Selection | undefined; @@ -113,7 +118,10 @@ const assembleRequest = async ({ amount, recipient, selection, memo }: SendSlice { address: { altBech32m: recipient }, value: { - amount: toBaseUnit(BigNumber(amount), selection.asset.denom.exponent), + amount: toBaseUnit( + BigNumber(amount), + getDisplayDenomExponent(selection.asset.denomMetadata), + ), assetId: { inner: selection.asset.assetId.inner }, }, }, @@ -126,9 +134,16 @@ const assembleRequest = async ({ amount, recipient, selection, memo }: SendSlice }); }; -export const validateAmount = (asset: AssetBalance, amount: string): boolean => { - const balanceAmt = fromBaseUnitAmount(asset.amount, asset.denom.exponent); - return Boolean(amount) && BigNumber(amount).gt(balanceAmt); +const validateAmount = ( + asset: AssetBalance, + /** + * The amount that a user types into the interface will always be in the + * display denomination -- e.g., in `penumbra`, not in `upenumbra`. + */ + amountInDisplayDenom: string, +): boolean => { + const balanceAmt = fromBaseUnitAmountAndDenomMetadata(asset.amount, asset.denomMetadata); + return Boolean(amountInDisplayDenom) && BigNumber(amountInDisplayDenom).gt(balanceAmt); }; export interface SendValidationFields { diff --git a/apps/webapp/src/state/swap.test.ts b/apps/webapp/src/state/swap.test.ts index 7e19611ac5..a5a601612f 100644 --- a/apps/webapp/src/state/swap.test.ts +++ b/apps/webapp/src/state/swap.test.ts @@ -2,7 +2,10 @@ import { create, StoreApi, UseBoundStore } from 'zustand'; import { AllSlices, initializeStore } from './index'; import { beforeEach, describe, expect, test } from 'vitest'; import { AssetBalance } from '../fetchers/balances'; -import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { + AssetId, + DenomMetadata, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1alpha1/num_pb'; import { stringToUint8Array } from '@penumbra-zone/types'; import { Selection } from './types'; @@ -10,10 +13,10 @@ import { localAssets } from '@penumbra-zone/constants'; describe('Swap Slice', () => { const assetBalance: AssetBalance = { - denom: { + denomMetadata: new DenomMetadata({ display: 'xyz', - exponent: 3, - }, + denomUnits: [{ denom: 'xyz', exponent: 3 }], + }), assetId: new AssetId({ inner: stringToUint8Array('abcdefg') }), amount: new Amount(), usdcValue: 1234, diff --git a/apps/webapp/src/state/swap.ts b/apps/webapp/src/state/swap.ts index 8afa4b2af5..422aba5230 100644 --- a/apps/webapp/src/state/swap.ts +++ b/apps/webapp/src/state/swap.ts @@ -9,6 +9,7 @@ import { getAddressByIndex } from '../fetchers/address'; import BigNumber from 'bignumber.js'; import { planWitnessBuildBroadcast } from './helpers'; import { DenomMetadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { getDisplayDenomExponent } from '@penumbra-zone/types/src/denom-metadata'; export interface SwapSlice { assetIn: Selection | undefined; @@ -81,7 +82,10 @@ const assembleRequest = async ({ assetIn, amount, assetOut }: SwapSlice) => { { targetAsset: assetOut.penumbraAssetId, value: { - amount: toBaseUnit(BigNumber(amount), assetIn.asset.denom.exponent), + amount: toBaseUnit( + BigNumber(amount), + getDisplayDenomExponent(assetIn.asset.denomMetadata), + ), assetId: assetIn.asset.assetId, }, claimAddress: await getAddressByIndex(assetIn.accountIndex), @@ -89,7 +93,10 @@ const assembleRequest = async ({ assetIn, amount, assetOut }: SwapSlice) => { // Asset Id should almost certainly be upenumbra, // may need to indicate native denom in registry fee: { - amount: toBaseUnit(BigNumber(amount), assetIn.asset.denom.exponent), + amount: toBaseUnit( + BigNumber(amount), + getDisplayDenomExponent(assetIn.asset.denomMetadata), + ), assetId: assetIn.asset.assetId, }, }, diff --git a/packages/types/src/amount.test.ts b/packages/types/src/amount.test.ts index 4a4f5b97d4..6ab3407e5d 100644 --- a/packages/types/src/amount.test.ts +++ b/packages/types/src/amount.test.ts @@ -4,12 +4,14 @@ import { displayAmount, displayUsd, fromBaseUnitAmount, + fromBaseUnitAmountAndDenomMetadata, joinLoHiAmount, } from './amount'; import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1alpha1/num_pb'; +import { DenomMetadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; describe('lohi helpers', () => { - it('convertFromBaseUnitAmount works', () => { + it('fromBaseUnitAmount works', () => { const result = fromBaseUnitAmount(new Amount({ lo: 1000n, hi: 5n }), 6); expect(result.toString()).toBe('92233720368547.75908'); }); @@ -19,6 +21,33 @@ describe('lohi helpers', () => { const hi = 18446744073709551615n; expect(joinLoHiAmount(new Amount({ lo, hi }))).toBe(340282366920938463463374607431768211455n); }); + + it('fromBaseUnitAmountAndDenomMetadata works', () => { + const penumbraDenomMetadata = new DenomMetadata({ + display: 'penumbra', + denomUnits: [ + { + denom: 'penumbra', + exponent: 6, + }, + { + denom: 'mpenumbra', + exponent: 3, + }, + { + denom: 'upenumbra', + exponent: 0, + }, + ], + }); + + const result = fromBaseUnitAmountAndDenomMetadata( + new Amount({ lo: 123456789n, hi: 0n }), + penumbraDenomMetadata, + ); + + expect(result.toString()).toBe('123.456789'); + }); }); describe('addAmounts', () => { diff --git a/packages/types/src/amount.ts b/packages/types/src/amount.ts index b48177e353..a23785b663 100644 --- a/packages/types/src/amount.ts +++ b/packages/types/src/amount.ts @@ -1,15 +1,24 @@ import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1alpha1/num_pb'; import { fromBaseUnit, joinLoHi, splitLoHi } from './lo-hi'; import BigNumber from 'bignumber.js'; +import { DenomMetadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { getDisplayDenomExponent } from './denom-metadata'; export const joinLoHiAmount = (amount: Amount): bigint => { return joinLoHi(amount.lo, amount.hi); }; -export const fromBaseUnitAmount = (amount: Amount, exponent: number): BigNumber => { +export const fromBaseUnitAmount = (amount: Amount, exponent = 0): BigNumber => { return fromBaseUnit(amount.lo, amount.hi, exponent); }; +export const fromBaseUnitAmountAndDenomMetadata = ( + amount: Amount, + denomMetadata: DenomMetadata, +): BigNumber => { + return fromBaseUnitAmount(amount, getDisplayDenomExponent(denomMetadata)); +}; + export const addAmounts = (a: Amount, b: Amount): Amount => { const joined = joinLoHiAmount(a) + joinLoHiAmount(b); const { lo, hi } = splitLoHi(joined); diff --git a/packages/types/src/denom-metadata.test.ts b/packages/types/src/denom-metadata.test.ts new file mode 100644 index 0000000000..22783316a6 --- /dev/null +++ b/packages/types/src/denom-metadata.test.ts @@ -0,0 +1,27 @@ +import { DenomMetadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { describe, expect, test } from 'vitest'; +import { getDisplayDenomExponent } from './denom-metadata'; + +describe('getDisplayDenomExponent()', () => { + test("gets the exponent from the denom unit whose `denom` is equal to the metadata's `display` property", () => { + const penumbraDenomMetadata = new DenomMetadata({ + display: 'penumbra', + denomUnits: [ + { + denom: 'penumbra', + exponent: 6, + }, + { + denom: 'mpenumbra', + exponent: 3, + }, + { + denom: 'upenumbra', + exponent: 0, + }, + ], + }); + + expect(getDisplayDenomExponent(penumbraDenomMetadata)).toBe(6); + }); +}); diff --git a/packages/types/src/denom-metadata.ts b/packages/types/src/denom-metadata.ts new file mode 100644 index 0000000000..35204b49e5 --- /dev/null +++ b/packages/types/src/denom-metadata.ts @@ -0,0 +1,17 @@ +import { DenomMetadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; + +/** + * Returns the exponent for a given asset type's display denom unit, given that + * denom's metadata. + * + * `DenomMetadata`s have an array of `DenomUnit`s, describing the exponent of + * each denomination in relation to the base unit. For example, upenumbra is + * penumbra's base unit -- the unit which can not be further divided into + * decimals. 1 penumbra is equal to 1,000,000 (AKA, 10 to the 6th) upenumbra, so + * penumbra's display exponent -- the exponent used to multiply the base unit + * when displaying a penumbra value to a user -- is 6. (For a non-crypto + * example, think of US dollars. The dollar is the display unit; the cent is the + * base unit; the display exponent is 2 (10 to the 2nd).) + */ +export const getDisplayDenomExponent = (denomMetadata: DenomMetadata): number | undefined => + denomMetadata.denomUnits.find(denomUnit => denomUnit.denom === denomMetadata.display)?.exponent; diff --git a/packages/types/src/lo-hi.ts b/packages/types/src/lo-hi.ts index 6bf5a92285..07524a5561 100644 --- a/packages/types/src/lo-hi.ts +++ b/packages/types/src/lo-hi.ts @@ -76,7 +76,7 @@ export const fromBaseUnit = (lo = 0n, hi = 0n, exponent: number): BigNumber => { * @param {number} exponent - The exponent to be applied. * @returns {LoHi} An object with properties `lo` and `hi`, representing the low and high 64 bits of the multiplied value. */ -export const toBaseUnit = (value: BigNumber, exponent: number): LoHi => { +export const toBaseUnit = (value: BigNumber, exponent = 0): LoHi => { const multipliedValue = value.multipliedBy(new BigNumber(10).pow(exponent)); const bigInt = BigInt(multipliedValue.toFixed()); diff --git a/packages/ui/components/ui/tx/view/value.test.tsx b/packages/ui/components/ui/tx/view/value.test.tsx new file mode 100644 index 0000000000..f1c3a6d1d1 --- /dev/null +++ b/packages/ui/components/ui/tx/view/value.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest'; +import { ValueViewComponent } from './value'; +import { render } from '@testing-library/react'; +import { + DenomMetadata, + ValueView, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { base64ToUint8Array, bech32AssetId } from '@penumbra-zone/types'; + +describe('', () => { + const penumbraDenomMetadata = new DenomMetadata({ + base: 'upenumbra', + display: 'penumbra', + penumbraAssetId: { + inner: base64ToUint8Array('KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA='), + }, + images: [ + { + png: 'https://raw.githubusercontent.com/penumbra-zone/web/main/apps/webapp/public/favicon.png', + }, + ], + denomUnits: [ + { + denom: 'penumbra', + exponent: 6, + }, + { + denom: 'mpenumbra', + exponent: 3, + }, + { + denom: 'upenumbra', + exponent: 0, + }, + ], + }); + + describe('when rendering a known denomination', () => { + const valueView = new ValueView({ + valueView: { + case: 'knownDenom', + value: { + amount: { + hi: 0n, + lo: 123_456_789n, + }, + denom: penumbraDenomMetadata, + }, + }, + }); + + test('renders the amount in the display denom unit', () => { + const { container } = render(); + + expect(container).toHaveTextContent('123.456789 penumbra'); + }); + }); + + describe('when rendering an unknown denomination', () => { + const valueView = new ValueView({ + valueView: { + case: 'unknownDenom', + value: { + amount: { + hi: 0n, + lo: 123_456_789n, + }, + assetId: { + inner: penumbraDenomMetadata.penumbraAssetId!.inner, + }, + }, + }, + }); + + test('renders the amount in the base unit, along with an asset ID', () => { + const { container } = render(); + const assetIdAsString = bech32AssetId(penumbraDenomMetadata.penumbraAssetId!); + + expect(container).toHaveTextContent(`123,456,789${assetIdAsString}`); + }); + }); +}); diff --git a/packages/ui/components/ui/tx/view/value.tsx b/packages/ui/components/ui/tx/view/value.tsx index 86d8f8a66b..51767cc9c5 100644 --- a/packages/ui/components/ui/tx/view/value.tsx +++ b/packages/ui/components/ui/tx/view/value.tsx @@ -3,12 +3,13 @@ import { bech32AssetId, fromBaseUnitAmount } from '@penumbra-zone/types'; import { CopyToClipboard } from '../../copy-to-clipboard'; import { CopyIcon } from '@radix-ui/react-icons'; import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1alpha1/num_pb'; +import { getDisplayDenomExponent } from '@penumbra-zone/types/src/denom-metadata'; -interface ValueViewPrpos { +interface ValueViewProps { view: ValueView | undefined; } -export const ValueViewComponent = ({ view }: ValueViewPrpos) => { +export const ValueViewComponent = ({ view }: ValueViewProps) => { if (!view) return <>; if (view.valueView.case === 'unknownDenom') { @@ -17,7 +18,7 @@ export const ValueViewComponent = ({ view }: ValueViewPrpos) => { const encodedAssetId = bech32AssetId(value.assetId!); return (
-

{fromBaseUnitAmount(amount, 1).toFormat()}

+

{fromBaseUnitAmount(amount, 0).toFormat()}

{encodedAssetId} { const value = view.valueView.value; const amount = value.amount ?? new Amount(); const display_denom = value.denom?.display ?? ''; - // The first denom unit in the list is the display denom, according to cosmos practice - const exponent = value.denom?.denomUnits[0]?.exponent ?? 1; + const exponent = value.denom ? getDisplayDenomExponent(value.denom) : 0; + return (
{fromBaseUnitAmount(amount, exponent).toFormat()} {display_denom}