From 7d4e776c6ad78e5923327e1e3bd8bb9bdd5c1d60 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Thu, 1 Feb 2024 10:40:02 +0100 Subject: [PATCH] Refactor balances.ts to use protobuf types --- .../extension/src/routes/popup/home/index.tsx | 6 +- apps/extension/src/state/wallets.ts | 23 ++- apps/webapp/package.json | 2 + apps/webapp/src/clients/penumbra-port.ts | 2 +- .../src/components/dashboard/assets-table.tsx | 112 ++++++------- .../src/components/send/ibc/ibc-form.tsx | 20 +-- apps/webapp/src/components/send/receive.tsx | 4 +- .../src/components/send/send-form/index.tsx | 20 +-- .../src/components/shared/asset-icon.tsx | 14 +- .../src/components/shared/input-token.tsx | 32 +--- .../components/shared/select-token-modal.tsx | 80 ++++----- .../src/components/swap/asset-out-box.tsx | 47 +++--- .../components/swap/asset-out-selector.tsx | 14 +- apps/webapp/src/components/swap/layout.tsx | 13 +- apps/webapp/src/components/swap/swap-form.tsx | 4 +- apps/webapp/src/fetchers/address.ts | 10 +- apps/webapp/src/fetchers/balances.ts | 152 ------------------ apps/webapp/src/fetchers/balances/balances.ts | 98 +++++++++++ .../src/fetchers/balances/by-account.ts | 40 +++++ apps/webapp/src/state/ibc.test.ts | 64 +++++--- apps/webapp/src/state/ibc.ts | 24 +-- apps/webapp/src/state/send.test.ts | 81 ++++++---- apps/webapp/src/state/send.ts | 74 ++++++--- apps/webapp/src/state/swap.test.ts | 52 +++--- apps/webapp/src/state/swap.ts | 40 +++-- apps/webapp/src/state/types.ts | 7 - package.json | 2 +- .../asset-metadata-by-id.ts | 31 ++++ .../src/grpc/view-protocol-server/index.ts | 5 +- .../transaction-planner.ts | 27 +--- packages/tsconfig/base.json | 1 - packages/types/src/account.ts | 4 - packages/types/src/amount.test.ts | 11 +- packages/types/src/amount.ts | 10 +- packages/types/src/index.ts | 4 +- packages/types/src/schemas/address-index.ts | 6 + packages/types/src/schemas/address.ts | 6 + packages/types/src/schemas/asset-id.ts | 7 + .../types/src/schemas/asset-image--theme.ts | 7 + packages/types/src/schemas/asset-image.ts | 8 + packages/types/src/schemas/denom-metadata.ts | 15 ++ packages/types/src/schemas/denom-unit.ts | 7 + packages/types/src/schemas/index.ts | 7 + .../types/src/type-predicates/address-view.ts | 25 +++ packages/types/src/type-predicates/index.ts | 2 + .../types/src/type-predicates/value-view.ts | 14 ++ packages/types/src/validation.test.ts | 31 +++- packages/types/src/validation.ts | 51 +++++- .../ui/components/ui/address-component.tsx | 21 +++ packages/ui/components/ui/address-icon.tsx | 16 +- packages/ui/components/ui/address.test.tsx | 14 +- packages/ui/components/ui/address.tsx | 17 -- packages/ui/components/ui/identicon/index.tsx | 12 +- packages/ui/components/ui/identicon/types.ts | 2 +- packages/ui/components/ui/select-account.tsx | 40 ++--- .../ui/components/ui/tx/view/address-view.tsx | 7 +- packages/ui/components/ui/tx/view/value.tsx | 28 +++- packages/ui/components/ui/types/address.tsx | 0 packages/ui/components/ui/types/value.tsx | 0 packages/ui/package.json | 2 +- pnpm-lock.yaml | 14 +- 61 files changed, 886 insertions(+), 603 deletions(-) delete mode 100644 apps/webapp/src/fetchers/balances.ts create mode 100644 apps/webapp/src/fetchers/balances/balances.ts create mode 100644 apps/webapp/src/fetchers/balances/by-account.ts delete mode 100644 apps/webapp/src/state/types.ts create mode 100644 packages/router/src/grpc/view-protocol-server/asset-metadata-by-id.ts delete mode 100644 packages/types/src/account.ts create mode 100644 packages/types/src/schemas/address-index.ts create mode 100644 packages/types/src/schemas/address.ts create mode 100644 packages/types/src/schemas/asset-id.ts create mode 100644 packages/types/src/schemas/asset-image--theme.ts create mode 100644 packages/types/src/schemas/asset-image.ts create mode 100644 packages/types/src/schemas/denom-metadata.ts create mode 100644 packages/types/src/schemas/denom-unit.ts create mode 100644 packages/types/src/schemas/index.ts create mode 100644 packages/types/src/type-predicates/address-view.ts create mode 100644 packages/types/src/type-predicates/index.ts create mode 100644 packages/types/src/type-predicates/value-view.ts create mode 100644 packages/ui/components/ui/address-component.tsx delete mode 100644 packages/ui/components/ui/types/address.tsx delete mode 100644 packages/ui/components/ui/types/value.tsx diff --git a/apps/extension/src/routes/popup/home/index.tsx b/apps/extension/src/routes/popup/home/index.tsx index 41f4c521b7..c4123cbee8 100644 --- a/apps/extension/src/routes/popup/home/index.tsx +++ b/apps/extension/src/routes/popup/home/index.tsx @@ -5,7 +5,7 @@ import { IndexHeader } from './index-header'; import { useStore } from '../../../state'; import { BlockSync } from './block-sync'; import { localExtStorage, sessionExtStorage } from '@penumbra-zone/storage'; -import { accountAddrSelector } from '../../../state/wallets'; +import { addrByIndexSelector } from '../../../state/wallets'; export interface PopupLoaderData { lastBlockSynced: number; @@ -32,14 +32,14 @@ export const popupIndexLoader = async (): Promise => }; export const PopupIndex = () => { - const getAccount = useStore(accountAddrSelector); + const getAccount = useStore(addrByIndexSelector); return (
- +
diff --git a/apps/extension/src/state/wallets.ts b/apps/extension/src/state/wallets.ts index a3e07e058e..1424334a5d 100644 --- a/apps/extension/src/state/wallets.ts +++ b/apps/extension/src/state/wallets.ts @@ -8,7 +8,8 @@ import { } from '@penumbra-zone/wasm-ts'; import { Key } from '@penumbra-zone/crypto-web'; import { ExtensionStorage, LocalStorageState } from '@penumbra-zone/storage'; -import { Wallet, WalletCreate, bech32Address } from '@penumbra-zone/types'; +import { Wallet, WalletCreate } from '@penumbra-zone/types'; +import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; export interface WalletsSlice { all: Wallet[]; @@ -74,17 +75,13 @@ export const createWalletsSlice = export const walletsSelector = (state: AllSlices) => state.wallets; export const getActiveWallet = (state: AllSlices) => state.wallets.all[0]; -export const accountAddrSelector = (state: AllSlices) => (index: number, ephemeral: boolean) => { - const active = getActiveWallet(state); - if (!active) return; +export const addrByIndexSelector = + (state: AllSlices) => + (index: number, ephemeral: boolean): Address => { + const active = getActiveWallet(state); + if (!active) throw new Error('No active wallet'); - const addr = ephemeral - ? getEphemeralByIndex(active.fullViewingKey, index) - : getAddressByIndex(active.fullViewingKey, index); - const bech32Addr = bech32Address(addr); - - return { - address: bech32Addr, - index, + return ephemeral + ? getEphemeralByIndex(active.fullViewingKey, index) + : getAddressByIndex(active.fullViewingKey, index); }; -}; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index afa6358daf..90ff6d5560 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -19,12 +19,14 @@ "@radix-ui/react-icons": "^1.3.0", "@tanstack/react-query": "^5.18.1", "bignumber.js": "^9.1.2", + "immer": "^10.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-loader-spinner": "^6.1.6", "react-router-dom": "^6.22.0", "tailwindcss": "^3.4.1", + "zod": "^3.22.4", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/apps/webapp/src/clients/penumbra-port.ts b/apps/webapp/src/clients/penumbra-port.ts index 584afbf72e..4a40660214 100644 --- a/apps/webapp/src/clients/penumbra-port.ts +++ b/apps/webapp/src/clients/penumbra-port.ts @@ -16,7 +16,7 @@ declare global { export const getPenumbraPort = (serviceTypeName: string) => { const { port1: port, port2: transferPort } = new MessageChannel(); if (!(penumbra in window)) throw Error('No Penumbra global (extension not installed)'); - const initPort = window[penumbra].services?.[serviceTypeName]; + const initPort = window[penumbra]?.services?.[serviceTypeName]; if (!initPort) throw Error(`No init port for service ${serviceTypeName}`); initPort.postMessage( { diff --git a/apps/webapp/src/components/dashboard/assets-table.tsx b/apps/webapp/src/components/dashboard/assets-table.tsx index e151c737db..635ccb2c64 100644 --- a/apps/webapp/src/components/dashboard/assets-table.tsx +++ b/apps/webapp/src/components/dashboard/assets-table.tsx @@ -1,19 +1,23 @@ -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@penumbra-zone/ui'; -import { displayUsd, fromBaseUnitAmountAndMetadata } 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'; -import { AssetIcon } from '../shared/asset-icon.tsx'; import { AddressIcon } from '@penumbra-zone/ui/components/ui/address-icon'; -import { Address } from '@penumbra-zone/ui/components/ui/address.tsx'; +import { AddressComponent } from '@penumbra-zone/ui/components/ui/address-component'; +import { + AccountGroupedBalances, + getBalancesByAccount, +} from '../../fetchers/balances/by-account.ts'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@penumbra-zone/ui'; +import { AssetIcon } from '../shared/asset-icon.tsx'; +import { displayUsd, hasDenomMetadata } from '@penumbra-zone/types'; +import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value.tsx'; -export const AssetsLoader: LoaderFunction = async (): Promise => { +export const AssetsLoader: LoaderFunction = async (): Promise => { throwIfExtNotInstalled(); return await getBalancesByAccount(); }; export default function AssetsTable() { - const data = useLoaderData() as AccountBalance[]; + const data = useLoaderData() as AccountGroupedBalances[]; if (data.length === 0) { return ( @@ -31,73 +35,63 @@ export default function AssetsTable() { return (
- {data.map(a => { - return ( -
-
-
-
- -

Account #{a.index}

{' '} -
- -
+ {data.map((a, index) => ( +
+
+
+
+ +

Account #{a.index.account}

+
-
- {a.balances.map((asset, i) => ( -
-
- -

{asset.metadata.display}

-
-

- {fromBaseUnitAmountAndMetadata(asset.amount, asset.metadata).toFormat()} -

-

- {asset.usdcValue == 0 ? '$–' : `$${displayUsd(asset.usdcValue)}`} -

-
- ))} -
- - - - Asset - Balance - Value - - - - {a.balances.map((asset, i) => ( - + + +
+ + + Asset + Balance + Value + + + + {a.balances.map((assetBalance, index) => { + return ( +
- -

{asset.metadata.display}

+ {hasDenomMetadata(assetBalance.value) && ( + <> + +

+ {assetBalance.value.valueView.value.metadata.display} +

+ + )}
-
-

- {fromBaseUnitAmountAndMetadata(asset.amount, asset.metadata).toFormat()} -

-
+

+ +

- {asset.usdcValue == 0 ? '$–' : `$${displayUsd(asset.usdcValue)}`} + {assetBalance.usdcValue == 0 + ? '$–' + : `$${displayUsd(assetBalance.usdcValue)}`}

- ))} -
-
-
- ); - })} + ); + })} + + +
+ ))}
); } diff --git a/apps/webapp/src/components/send/ibc/ibc-form.tsx b/apps/webapp/src/components/send/ibc/ibc-form.tsx index fc158a822a..0da4ece2e1 100644 --- a/apps/webapp/src/components/send/ibc/ibc-form.tsx +++ b/apps/webapp/src/components/send/ibc/ibc-form.tsx @@ -8,20 +8,16 @@ import InputToken from '../../shared/input-token'; import { sendValidationErrors } from '../../../state/send'; import { useMemo } from 'react'; import { LoaderFunction, useLoaderData } from 'react-router-dom'; -import { AccountBalance, getBalancesByAccount } from '../../../fetchers/balances'; +import { AssetBalance, getAssetBalances } from '../../../fetchers/balances/balances.ts'; import { penumbraAddrValidation } from '../helpers'; -export const IbcAssetBalanceLoader: LoaderFunction = async (): Promise => { - const balancesByAccount = await getBalancesByAccount(); +export const IbcAssetBalanceLoader: LoaderFunction = async (): Promise => { + const balancesByAccount = await getAssetBalances(); if (balancesByAccount[0]) { // set initial account if accounts exist and asset if account has asset list useStore.setState(state => { - state.ibc.selection = { - address: balancesByAccount[0]?.address, - accountIndex: balancesByAccount[0]?.index, - asset: balancesByAccount[0]?.balances[0], - }; + state.ibc.selection = balancesByAccount[0]; }); } @@ -29,7 +25,7 @@ export const IbcAssetBalanceLoader: LoaderFunction = async (): Promise { - return sendValidationErrors(selection?.asset, amount, destinationChainAddress); - }, [selection?.asset, amount, destinationChainAddress]); + return sendValidationErrors(selection, amount, destinationChainAddress); + }, [selection, amount, destinationChainAddress]); return (
Send diff --git a/apps/webapp/src/components/send/receive.tsx b/apps/webapp/src/components/send/receive.tsx index fcffc15512..f3dd665d36 100644 --- a/apps/webapp/src/components/send/receive.tsx +++ b/apps/webapp/src/components/send/receive.tsx @@ -1,10 +1,10 @@ import { SelectAccount } from '@penumbra-zone/ui'; -import { getAccountAddr } from '../../fetchers/address'; +import { getAddrByIndex } from '../../fetchers/address'; export const Receive = () => { return (
- +
); }; diff --git a/apps/webapp/src/components/send/send-form/index.tsx b/apps/webapp/src/components/send/send-form/index.tsx index 4730d93078..1b9c232a4e 100644 --- a/apps/webapp/src/components/send/send-form/index.tsx +++ b/apps/webapp/src/components/send/send-form/index.tsx @@ -4,7 +4,7 @@ import { sendSelector, sendValidationErrors } from '../../../state/send.ts'; import { useToast } from '@penumbra-zone/ui/components/ui/use-toast'; import { InputBlock } from '../../shared/input-block'; import { LoaderFunction, useLoaderData } from 'react-router-dom'; -import { AccountBalance, getBalancesByAccount } from '../../../fetchers/balances'; +import { AssetBalance, getAssetBalances } from '../../../fetchers/balances/balances.ts'; import { useMemo } from 'react'; import { penumbraAddrValidation } from '../helpers'; import { throwIfExtNotInstalled } from '../../../fetchers/is-connected'; @@ -12,18 +12,14 @@ import InputToken from '../../shared/input-token.tsx'; import { useRefreshFee } from './use-refresh-fee.ts'; import { GasFee } from '../../shared/gas-fee.tsx'; -export const SendAssetBalanceLoader: LoaderFunction = async (): Promise => { +export const SendAssetBalanceLoader: LoaderFunction = async (): Promise => { throwIfExtNotInstalled(); - const balancesByAccount = await getBalancesByAccount(); + const balancesByAccount = await getAssetBalances(); if (balancesByAccount[0]) { // set initial account if accounts exist and asset if account has asset list useStore.setState(state => { - state.send.selection = { - address: balancesByAccount[0]?.address, - accountIndex: balancesByAccount[0]?.index, - asset: balancesByAccount[0]?.balances[0], - }; + state.send.selection = balancesByAccount[0]; }); } @@ -31,7 +27,7 @@ export const SendAssetBalanceLoader: LoaderFunction = async (): Promise { - const accountBalances = useLoaderData() as AccountBalance[]; + const accountBalances = useLoaderData() as AssetBalance[]; const { toast } = useToast(); const { selection, @@ -52,8 +48,8 @@ export const SendForm = () => { useRefreshFee(); const validationErrors = useMemo(() => { - return sendValidationErrors(selection?.asset, amount, recipient); - }, [selection?.asset, amount, recipient]); + return sendValidationErrors(selection, amount, recipient); + }, [selection, amount, recipient]); return ( { !recipient || !!Object.values(validationErrors).find(Boolean) || txInProgress || - !selection?.asset + !selection } > Send diff --git a/apps/webapp/src/components/shared/asset-icon.tsx b/apps/webapp/src/components/shared/asset-icon.tsx index 3bf79b30fb..841bc635e4 100644 --- a/apps/webapp/src/components/shared/asset-icon.tsx +++ b/apps/webapp/src/components/shared/asset-icon.tsx @@ -1,21 +1,27 @@ import { localAssets } from '@penumbra-zone/constants'; import { Identicon } from '@penumbra-zone/ui'; import { useMemo } from 'react'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; -export const AssetIcon = ({ name }: { name: string }) => { +export const AssetIcon = ({ metadata }: { metadata: Metadata }) => { const icon = useMemo(() => { - const assetImage = localAssets.find(i => i.display === name)?.images[0]; + const assetImage = localAssets.find(d => d.display === metadata.display)?.images[0]; // Image default is "" and thus cannot do nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return assetImage?.png || assetImage?.svg; - }, [name]); + }, [metadata]); return ( <> {icon ? ( Asset icon ) : ( - + )} ); diff --git a/apps/webapp/src/components/shared/input-token.tsx b/apps/webapp/src/components/shared/input-token.tsx index 525f7edbb2..03cf523c23 100644 --- a/apps/webapp/src/components/shared/input-token.tsx +++ b/apps/webapp/src/components/shared/input-token.tsx @@ -2,40 +2,20 @@ import { Input, InputProps } from '@penumbra-zone/ui'; import { cn } from '@penumbra-zone/ui/lib/utils'; import SelectTokenModal from './select-token-modal'; import { Validation } from './validation-result'; -import { AccountBalance, AssetBalance } from '../../fetchers/balances'; -import { Selection } from '../../state/types'; +import { AssetBalance } from '../../fetchers/balances/balances.ts'; 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'; import { InputBlock } from './input-block'; -const getCurrentBalanceValueView = (assetBalance: AssetBalance | undefined): ValueView => { - if (assetBalance?.metadata) - return new ValueView({ - valueView: { - case: 'knownAssetId', - value: { amount: assetBalance.amount, metadata: assetBalance.metadata }, - }, - }); - else if (assetBalance?.assetId) - return new ValueView({ - valueView: { - case: 'unknownAssetId', - value: { amount: assetBalance.amount, assetId: assetBalance.assetId }, - }, - }); - else return new ValueView(); -}; - interface InputTokenProps extends InputProps { label: string; - selection: Selection | undefined; + selection: AssetBalance | undefined; placeholder: string; className?: string; inputClassName?: string; value: string; - setSelection: (selection: Selection) => void; + setSelection: (selection: AssetBalance) => void; validations?: Validation[]; - balances: AccountBalance[]; + balances: AssetBalance[]; } export default function InputToken({ @@ -50,8 +30,6 @@ export default function InputToken({ balances, ...props }: InputTokenProps) { - const currentBalanceValueView = getCurrentBalanceValueView(selection?.asset); - return (
@@ -72,7 +50,7 @@ export default function InputToken({
Wallet - +
diff --git a/apps/webapp/src/components/shared/select-token-modal.tsx b/apps/webapp/src/components/shared/select-token-modal.tsx index 5d8f0c11ff..40affe066d 100644 --- a/apps/webapp/src/components/shared/select-token-modal.tsx +++ b/apps/webapp/src/components/shared/select-token-modal.tsx @@ -8,16 +8,18 @@ import { DialogTrigger, Input, } from '@penumbra-zone/ui'; -import { fromBaseUnitAmountAndMetadata } from '@penumbra-zone/types'; import { cn } from '@penumbra-zone/ui/lib/utils'; -import { AccountBalance } from '../../fetchers/balances'; +import { AssetBalance } from '../../fetchers/balances/balances.ts'; import { AssetIcon } from './asset-icon'; -import { Selection } from '../../state/types'; +import { + getDisplayDenomFromView, + ValueViewComponent, +} from '@penumbra-zone/ui/components/ui/tx/view/value.tsx'; interface SelectTokenModalProps { - selection: Selection | undefined; - setSelection: (selection: Selection) => void; - balances: AccountBalance[]; + selection: AssetBalance | undefined; + setSelection: (selection: AssetBalance) => void; + balances: AssetBalance[]; } export default function SelectTokenModal({ @@ -27,16 +29,16 @@ export default function SelectTokenModal({ }: SelectTokenModalProps) { const [search, setSearch] = useState(''); + const displayDenom = selection && getDisplayDenomFromView(selection.value); + const denomMetadata = + selection?.value.valueView.case === 'knownAssetId' && selection.value.valueView.value.metadata; + return (
- {selection?.asset?.metadata.display && ( - - )} -

- {selection?.asset?.metadata.display} -

+ {denomMetadata && } +

{displayDenom}

@@ -60,32 +62,36 @@ export default function SelectTokenModal({

Balance

- {balances.map(b => ( -
- {b.balances.map((k, j) => ( - -
- setSelection({ accountIndex: b.index, address: b.address, asset: k }) - } - > -

{b.index}

-
- -

{k.metadata.display}

-
-

- {fromBaseUnitAmountAndMetadata(k.amount, k.metadata).toFormat()} -

+ {balances.map((b, i) => ( +
+ +
setSelection(b)} + > +

+ {b.address.addressView.case === 'decoded' && + b.address.addressView.value.index?.account + ? b.address.addressView.value.index.account + : '0'} +

+
+ {b.value.valueView.case === 'knownAssetId' && + b.value.valueView.value.metadata && ( + + )} +

{getDisplayDenomFromView(b.value)}

- - ))} +

+ +

+
+
))}
diff --git a/apps/webapp/src/components/swap/asset-out-box.tsx b/apps/webapp/src/components/swap/asset-out-box.tsx index 5d9a9bd9f8..bea19f35b7 100644 --- a/apps/webapp/src/components/swap/asset-out-box.tsx +++ b/apps/webapp/src/components/swap/asset-out-box.tsx @@ -1,41 +1,34 @@ import { useStore } from '../../state'; import { swapSelector } from '../../state/swap'; -import { AccountBalance, AssetBalance, groupByAsset } from '../../fetchers/balances'; +import { AssetBalance } from '../../fetchers/balances/balances.ts'; import { Input } from '@penumbra-zone/ui'; import { AssetOutSelector } from './asset-out-selector'; -import { - Metadata, - ValueView, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value'; +const findMatchingBalance = ( + denom: Metadata | undefined, + balances: AssetBalance[], +): AssetBalance | undefined => { + if (!denom?.penumbraAssetId) return undefined; + return balances.find(b => { + if (b.value.valueView.case !== 'knownAssetId') return false; + const assetId = b.value.valueView.value.metadata?.penumbraAssetId; + if (!assetId) return false; + return assetId.equals(denom.penumbraAssetId); + }); +}; + interface AssetOutBoxProps { - balances: AccountBalance[]; + balances: AssetBalance[]; } export const AssetOutBox = ({ balances }: AssetOutBoxProps) => { const { assetOut, setAssetOut } = useStore(swapSelector); - const aggregatedBalances = balances - .flatMap(b => b.balances) - .reduce(groupByAsset, []); - - // TODO: with https://github.com/penumbra-zone/web/issues/392 convert to use `getValueViewByAccount` - const balanceOfDenom = aggregatedBalances.find(b => b.assetId.equals(assetOut?.penumbraAssetId)); - const valueView = balanceOfDenom - ? new ValueView({ - valueView: { - case: 'knownAssetId', - value: { - amount: balanceOfDenom.amount, - metadata: new Metadata({ - display: balanceOfDenom.metadata.display, - denomUnits: balanceOfDenom.metadata.denomUnits, - }), - }, - }, - }) - : new ValueView(); + // TODO: need to aggregate balances of assets across accounts (.reduce()) + const aggregatedBalances = balances; + const matchingBalance = findMatchingBalance(assetOut, aggregatedBalances); return (
@@ -60,7 +53,7 @@ export const AssetOutBox = ({ balances }: AssetOutBoxProps) => {
Wallet - + {matchingBalance && }
diff --git a/apps/webapp/src/components/swap/asset-out-selector.tsx b/apps/webapp/src/components/swap/asset-out-selector.tsx index c6dea61e24..12985e1435 100644 --- a/apps/webapp/src/components/swap/asset-out-selector.tsx +++ b/apps/webapp/src/components/swap/asset-out-selector.tsx @@ -1,4 +1,4 @@ -import { AssetBalance } from '../../fetchers/balances'; +import { AssetBalance } from '../../fetchers/balances/balances.ts'; import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from '@penumbra-zone/ui'; import { AssetIcon } from '../shared/asset-icon'; import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; @@ -15,7 +15,7 @@ export const AssetOutSelector = ({ balances, setAssetOut, assetOut }: AssetOutSe
- {assetOut?.display && } + {assetOut?.display && }

{assetOut?.display}

@@ -24,16 +24,16 @@ export const AssetOutSelector = ({ balances, setAssetOut, assetOut }: AssetOutSe Select asset
- {localAssets.map(a => ( -
+ {localAssets.map(d => ( +
setAssetOut(a)} + onClick={() => setAssetOut(d)} >
- -

{a.display}

+ +

{d.display}

diff --git a/apps/webapp/src/components/swap/layout.tsx b/apps/webapp/src/components/swap/layout.tsx index fa5749109c..2da75ce827 100644 --- a/apps/webapp/src/components/swap/layout.tsx +++ b/apps/webapp/src/components/swap/layout.tsx @@ -1,6 +1,6 @@ import { Card } from '@penumbra-zone/ui'; import { LoaderFunction } from 'react-router-dom'; -import { AccountBalance, getBalancesByAccount } from '../../fetchers/balances'; +import { AssetBalance, getAssetBalances } from '../../fetchers/balances/balances.ts'; import { useStore } from '../../state'; import { throwIfExtNotInstalled } from '../../fetchers/is-connected'; import { EduInfoCard } from '../shared/edu-panels/edu-info-card'; @@ -8,19 +8,14 @@ import { EduPanel } from '../shared/edu-panels/content'; import { SwapForm } from './swap-form'; import { localAssets } from '@penumbra-zone/constants'; -export const SwapLoader: LoaderFunction = async (): Promise => { +export const SwapLoader: LoaderFunction = async (): Promise => { throwIfExtNotInstalled(); - const balancesByAccount = await getBalancesByAccount(); + const balancesByAccount = await getAssetBalances(); // set initial denom in if there is an available balance if (balancesByAccount[0]) { useStore.setState(state => { - state.swap.assetIn = { - address: balancesByAccount[0]?.address, - accountIndex: balancesByAccount[0]?.index, - asset: balancesByAccount[0]?.balances[0], - }; - + state.swap.assetIn = balancesByAccount[0]; state.swap.assetOut = localAssets[0]; }); } diff --git a/apps/webapp/src/components/swap/swap-form.tsx b/apps/webapp/src/components/swap/swap-form.tsx index 3a021caadc..0538f3e2ed 100644 --- a/apps/webapp/src/components/swap/swap-form.tsx +++ b/apps/webapp/src/components/swap/swap-form.tsx @@ -2,13 +2,13 @@ import { Button } from '@penumbra-zone/ui'; import { useToast } from '@penumbra-zone/ui/components/ui/use-toast'; import InputToken from '../shared/input-token'; import { useLoaderData } from 'react-router-dom'; -import { AccountBalance } from '../../fetchers/balances'; +import { AssetBalance } from '../../fetchers/balances/balances.ts'; import { useStore } from '../../state'; import { swapSelector } from '../../state/swap'; import { AssetOutBox } from './asset-out-box'; export const SwapForm = () => { - const accountBalances = useLoaderData() as AccountBalance[]; + const accountBalances = useLoaderData() as AssetBalance[]; const { toast } = useToast(); const { assetIn, setAssetIn, amount, setAmount, initiateSwapTx } = useStore(swapSelector); diff --git a/apps/webapp/src/fetchers/address.ts b/apps/webapp/src/fetchers/address.ts index 01ffb56e04..35cf0602b7 100644 --- a/apps/webapp/src/fetchers/address.ts +++ b/apps/webapp/src/fetchers/address.ts @@ -36,12 +36,6 @@ export const getEphemeralAddress = async (account = 0): Promise
=> { return address; }; -export const getAccountAddr = async (index: number, ephemeral: boolean) => { - const address = ephemeral ? await getEphemeralAddress(index) : await getAddressByIndex(index); - const bech32 = bech32Address(address); - - return { - address: bech32, - index, - }; +export const getAddrByIndex = async (index: number, ephemeral: boolean) => { + return ephemeral ? await getEphemeralAddress(index) : await getAddressByIndex(index); }; diff --git a/apps/webapp/src/fetchers/balances.ts b/apps/webapp/src/fetchers/balances.ts deleted file mode 100644 index cd19da3b1e..0000000000 --- a/apps/webapp/src/fetchers/balances.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - AssetsResponse, - BalancesRequest, - BalancesResponse, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1alpha1/view_pb'; -import { - AssetId, - Metadata, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; -import { getAddresses, IndexAddrRecord } from './address'; -import { getAllAssets } from './assets'; -import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1alpha1/num_pb'; -import { addAmounts, joinLoHiAmount, uint8ArrayToBase64 } from '@penumbra-zone/types'; -import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; -import { viewClient } from '../clients/grpc'; -import { streamToPromise } from './stream'; - -export interface AssetBalance { - metadata: Metadata; - assetId: AssetId; - amount: Amount; - usdcValue: number; -} - -export interface AccountBalance { - index: number; - address: string; - balances: AssetBalance[]; -} - -type NormalizedBalance = AssetBalance & { - account: { index: number; address: string }; -}; - -const getDenomAmount = ( - res: BalancesResponse, - metadata: AssetsResponse[], -): { amount: Amount; metadata: Metadata } => { - 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 amount = res.balance?.amount ?? new Amount(); - - return { amount, metadata: match?.denomMetadata ?? new Metadata() }; -}; - -const normalize = - (assetResponses: AssetsResponse[], indexAddrRecord: IndexAddrRecord | undefined) => - (res: BalancesResponse): NormalizedBalance => { - const index = res.account?.account ?? 0; - const address = indexAddrRecord?.[index] ?? ''; - - const { metadata, amount } = getDenomAmount(res, assetResponses); - - return { - metadata, - assetId: res.balance!.assetId!, - amount, - //usdcValue: amount * 0.93245, // TODO: Temporary until pricing implemented - usdcValue: 0, // Important not to imply that testnet balances have any value - account: { index, address }, - }; - }; - -const groupByAccount = (balances: AccountBalance[], curr: NormalizedBalance): AccountBalance[] => { - const match = balances.find(b => b.index === curr.account.index); - const newBalance = { - metadata: curr.metadata, - amount: curr.amount, - usdcValue: curr.usdcValue, - assetId: curr.assetId, - } satisfies AssetBalance; - - if (match) { - match.balances.push(newBalance); - match.balances.sort(sortByAmount); - } else { - balances.push({ - address: curr.account.address, - index: curr.account.index, - balances: [newBalance], - }); - } - return balances; -}; - -const sortByAmount = (a: AssetBalance, b: AssetBalance): number => { - // First, sort by asset value in descending order (largest to smallest). - if (a.usdcValue !== b.usdcValue) return b.usdcValue - a.usdcValue; - - // If values are equal, sort by amount descending - if (!a.amount.equals(b.amount)) - return Number(joinLoHiAmount(b.amount) - joinLoHiAmount(a.amount)); - - // If both are equal, sort by asset name in ascending order - return a.metadata.display.localeCompare(b.metadata.display); -}; - -// Sort by account (lowest first) -const sortByAccount = (a: AccountBalance, b: AccountBalance): number => a.index - b.index; - -// Accumulates totals per asset across accounts -export const groupByAsset = (balances: AssetBalance[], curr: AssetBalance): AssetBalance[] => { - const match = balances.find(b => b.assetId.equals(curr.assetId)); - if (match) { - match.amount = addAmounts(match.amount, curr.amount); - } else { - balances.push({ ...curr }); - } - return balances; -}; - -interface BalancesProps { - accountFilter?: AddressIndex; - assetIdFilter?: AssetId; -} - -export const getBalances = ({ accountFilter, assetIdFilter }: BalancesProps = {}) => { - const req = new BalancesRequest(); - if (accountFilter) req.accountFilter = accountFilter; - if (assetIdFilter) req.assetIdFilter = assetIdFilter; - - const iterable = viewClient.balances(req); - return streamToPromise(iterable); -}; - -const getBalancesWithMetadata = async (): Promise => { - const balances = await getBalances(); - const accounts = [...new Set(balances.map(b => b.account?.account))]; - const accountAddrs = await getAddresses(accounts); - const assets = await getAllAssets(); - return balances.map(normalize(assets, accountAddrs)); -}; - -// TODO: When simulation endpoint is supported, add pricing data here -export const getBalancesByAccount = async (): Promise => { - const balances = await getBalancesWithMetadata(); - const grouped = balances.reduce(groupByAccount, []); - grouped.sort(sortByAccount); - return grouped; -}; - -export const getBalancesByAccountIndex = async (accountIndex?: number): Promise => { - const balances = await getBalancesByAccount(); - return balances - .filter(a => !accountIndex || a.index === accountIndex) - .flatMap(a => a.balances) - .reduce(groupByAsset, []); -}; diff --git a/apps/webapp/src/fetchers/balances/balances.ts b/apps/webapp/src/fetchers/balances/balances.ts new file mode 100644 index 0000000000..4bc768369d --- /dev/null +++ b/apps/webapp/src/fetchers/balances/balances.ts @@ -0,0 +1,98 @@ +import { + BalancesRequest, + BalancesResponse, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1alpha1/view_pb'; +import { + AssetId, + Value, + ValueView, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { getAddressByIndex } from '../address.ts'; +import { + AddressIndex, + AddressView, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; +import { viewClient } from '../../clients/grpc.ts'; +import { streamToPromise } from '../stream.ts'; + +export interface AssetBalance { + value: ValueView; + address: AddressView; + usdcValue: number; +} + +interface BalancesProps { + accountFilter?: AddressIndex; + assetIdFilter?: AssetId; +} + +const getBalances = ({ accountFilter, assetIdFilter }: BalancesProps = {}) => { + const req = new BalancesRequest(); + if (accountFilter) req.accountFilter = accountFilter; + if (assetIdFilter) req.assetIdFilter = assetIdFilter; + + const iterable = viewClient.balances(req); + return streamToPromise(iterable); +}; + +export const getAssetBalances = async (): Promise => { + const balances = await getBalances(); + const balancePromises = balances.map(constructAssetBalanceWithMetadata); + return Promise.all(balancePromises); +}; + +const constructAssetBalanceWithMetadata = async ({ + balance, + account, +}: BalancesResponse): Promise => { + if (!balance) throw new Error('No balance in response'); + if (!account) throw new Error('No account in response'); + + const value = await getValueView(balance); + const address = await getAddressView(account); + const usdcValue = await calculateUsdcValue(balance); + + return { value, address, usdcValue }; +}; + +const getValueView = async (balance: Value): Promise => { + if (!balance.assetId) throw new Error('no asset id in balance'); + if (!balance.amount) throw new Error('no amount in balance'); + + const { denomMetadata } = await viewClient.assetMetadataById({ assetId: balance.assetId }); + + if (!denomMetadata) { + return new ValueView({ + valueView: { case: 'unknownAssetId', value: balance }, + }); + } else { + return new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: balance.amount, + metadata: denomMetadata, + }, + }, + }); + } +}; + +// @ts-expect-error TODO: implement actual pricing +// eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/require-await +const calculateUsdcValue = async (balance: Value): Promise => { + return 0; +}; + +const getAddressView = async (index: AddressIndex): Promise => { + const address = await getAddressByIndex(index.account); + return new AddressView({ + addressView: { + case: 'decoded', + value: { + address, + index, + }, + }, + }); +}; diff --git a/apps/webapp/src/fetchers/balances/by-account.ts b/apps/webapp/src/fetchers/balances/by-account.ts new file mode 100644 index 0000000000..26af61ee65 --- /dev/null +++ b/apps/webapp/src/fetchers/balances/by-account.ts @@ -0,0 +1,40 @@ +import { AssetBalance, getAssetBalances } from './balances.ts'; +import { + Address, + AddressIndex, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; + +export interface AccountGroupedBalances { + index: AddressIndex; + address: Address; + balances: AssetBalance[]; +} + +const groupByAccount = ( + acc: AccountGroupedBalances[], + curr: AssetBalance, +): AccountGroupedBalances[] => { + if (curr.address.addressView.case !== 'decoded') throw new Error('address is not decoded'); + if (!curr.address.addressView.value.address) throw new Error('no address in address view'); + if (!curr.address.addressView.value.index) throw new Error('no index in address view'); + + const index = curr.address.addressView.value.index; + const grouping = acc.find(a => a.index.equals(index)); + + if (grouping) { + grouping.balances.push(curr); + } else { + acc.push({ + index, + address: curr.address.addressView.value.address, + balances: [curr], + }); + } + + return acc; +}; + +export const getBalancesByAccount = async (): Promise => { + const balances = await getAssetBalances(); + return balances.reduce(groupByAccount, []); +}; diff --git a/apps/webapp/src/state/ibc.test.ts b/apps/webapp/src/state/ibc.test.ts index 9f920e9905..eeab6d95e2 100644 --- a/apps/webapp/src/state/ibc.test.ts +++ b/apps/webapp/src/state/ibc.test.ts @@ -1,32 +1,48 @@ 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 { bech32ToUint8Array, Chain } from '@penumbra-zone/types'; import { - AssetId, Metadata, + ValueView, } 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'; +import { AssetBalance } from '../fetchers/balances/balances.ts'; +import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; +import { produce } from 'immer'; // TODO: Revisit tests when re-implementing ibc form describe.skip('IBC Slice', () => { const selectionExample = { - asset: { - amount: new Amount({ - lo: 0n, - hi: 0n, - }), - metadata: new Metadata({ display: 'test_usd', denomUnits: [{ exponent: 18 }] }), - usdcValue: 0, - assetId: new AssetId().fromJson({ inner: 'reum7wQmk/owgvGMWMZn/6RFPV24zIKq3W6In/WwZgg=' }), - }, - address: - 'penumbra1e8k5c3ds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uurrgkvtjpny3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd1', - accountIndex: 0, - } satisfies Selection; + value: new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: new Amount({ + lo: 0n, + hi: 0n, + }), + metadata: new Metadata({ display: 'test_usd', denomUnits: [{ exponent: 18 }] }), + }, + }, + }), + address: new AddressView({ + addressView: { + case: 'opaque', + value: { + address: { + inner: bech32ToUint8Array( + 'penumbra1e8k5cyds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uu0rgkvtjpxy3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd4', + ), + }, + }, + }, + }), + usdcValue: 0, + } satisfies AssetBalance; + let useStore: UseBoundStore>; beforeEach(() => { @@ -47,26 +63,26 @@ describe.skip('IBC Slice', () => { test('validate high enough amount validates', () => { const assetBalance = new Amount({ hi: 1n }); - useStore.getState().send.setSelection({ - ...selectionExample, - asset: { ...selectionExample.asset, amount: assetBalance }, + const state = produce(selectionExample, draft => { + draft.value.valueView.value!.amount = assetBalance; }); + useStore.getState().send.setSelection(state); useStore.getState().send.setAmount('1'); const { selection, amount } = useStore.getState().send; - const { amountErr } = sendValidationErrors(selection?.asset, amount, 'xyz'); + const { amountErr } = sendValidationErrors(selection, amount, 'xyz'); expect(amountErr).toBeFalsy(); }); test('validate error when too low the balance of the asset', () => { const assetBalance = new Amount({ lo: 2n }); - useStore.getState().send.setSelection({ - ...selectionExample, - asset: { ...selectionExample.asset, amount: assetBalance }, + const state = produce(selectionExample, draft => { + draft.value.valueView.value!.amount = assetBalance; }); + useStore.getState().send.setSelection(state); useStore.getState().send.setAmount('6'); const { selection, amount } = useStore.getState().send; - const { amountErr } = sendValidationErrors(selection?.asset, amount, 'xyz'); + const { amountErr } = sendValidationErrors(selection, amount, 'xyz'); expect(amountErr).toBeTruthy(); }); }); diff --git a/apps/webapp/src/state/ibc.ts b/apps/webapp/src/state/ibc.ts index 15277c9d42..db0481072c 100644 --- a/apps/webapp/src/state/ibc.ts +++ b/apps/webapp/src/state/ibc.ts @@ -7,15 +7,14 @@ import BigNumber from 'bignumber.js'; import { typeRegistry } from '@penumbra-zone/types/src/registry'; import { ClientState } from '@buf/cosmos_ibc.bufbuild_es/ibc/lightclients/tendermint/v1/tendermint_pb'; import { Height } from '@buf/cosmos_ibc.bufbuild_es/ibc/core/client/v1/client_pb'; -import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; -import { Selection } from './types'; import { ibcClient, viewClient } from '../clients/grpc'; import { planWitnessBuildBroadcast } from './helpers'; import { getDisplayDenomExponent } from '@penumbra-zone/types/src/denom-metadata'; +import { AssetBalance } from '../fetchers/balances/balances.ts'; export interface IbcSendSlice { - selection: Selection | undefined; - setSelection: (selection: Selection) => void; + selection: AssetBalance | undefined; + setSelection: (selection: AssetBalance) => void; amount: string; setAmount: (amount: string) => void; chain: Chain | undefined; @@ -117,9 +116,11 @@ const getPlanRequest = async ({ }: IbcSendSlice): Promise => { if (!destinationChainAddress) throw new Error('no destination chain address set'); if (!chain?.ibcChannel) throw new Error('Chain ibc channel not available'); - - if (typeof selection?.accountIndex === 'undefined') throw new Error('no selected account'); - if (!selection.asset) throw new Error('no selected asset'); + if (selection?.value.valueView.case !== 'knownAssetId') throw new Error('unknown denom selected'); + if (!selection.value.valueView.value.metadata) throw new Error('no metadata in valueView'); + if (selection.address.addressView.case !== 'decoded') + throw new Error('address in view is not decoded'); + if (!selection.address.addressView.value.index) throw new Error('no index in addressView'); // TODO: implement source address in future, should correspond with asset selector? // TODO: change planner to fill this in automatically ? @@ -131,8 +132,11 @@ const getPlanRequest = async ({ return new TransactionPlannerRequest({ ics20Withdrawals: [ { - amount: toBaseUnit(BigNumber(amount), getDisplayDenomExponent(selection.asset.metadata)), - denom: { denom: selection.asset.metadata.display }, + amount: toBaseUnit( + BigNumber(amount), + getDisplayDenomExponent(selection.value.valueView.value.metadata), + ), + denom: { denom: selection.value.valueView.value.metadata.base }, destinationChainAddress, returnAddress, timeoutHeight, @@ -140,7 +144,7 @@ const getPlanRequest = async ({ sourceChannel: chain.ibcChannel, }, ], - source: new AddressIndex({ account: selection.accountIndex }), + source: selection.address.addressView.value.index, }); }; diff --git a/apps/webapp/src/state/send.test.ts b/apps/webapp/src/state/send.test.ts index 5c16f0d426..60d87384f4 100644 --- a/apps/webapp/src/state/send.test.ts +++ b/apps/webapp/src/state/send.test.ts @@ -3,17 +3,20 @@ 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 { AssetBalance } from '../fetchers/balances/balances.ts'; +import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; +import { bech32ToUint8Array, stringToUint8Array } from '@penumbra-zone/types'; import { - AssetId, Metadata, + ValueView, } 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 { produce } from 'immer'; 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'; +import { Fee } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1alpha1/fee_pb'; vi.mock('../fetchers/address', () => ({ getAddressByIndex: vi.fn(), @@ -21,19 +24,37 @@ vi.mock('../fetchers/address', () => ({ describe('Send Slice', () => { const selectionExample = { - asset: { - amount: new Amount({ - lo: 0n, - hi: 0n, - }), - metadata: new Metadata({ display: 'test_usd', denomUnits: [{ exponent: 18 }] }), - usdcValue: 0, - assetId: new AssetId().fromJson({ inner: 'reum7wQmk/owgvGMWMZn/6RFPV24zIKq3W6In/WwZgg=' }), - }, - address: - 'penumbra1e8k5c3ds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uurrgkvtjpny3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd1', - accountIndex: 0, - } satisfies Selection; + value: new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: new Amount({ + lo: 0n, + hi: 0n, + }), + metadata: new Metadata({ + display: 'test_usd', + denomUnits: [{ exponent: 18 }], + penumbraAssetId: { inner: stringToUint8Array('passet1239049023') }, + }), + }, + }, + }), + address: new AddressView({ + addressView: { + case: 'decoded', + value: { + address: { + inner: bech32ToUint8Array( + 'penumbra1e8k5cyds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uu0rgkvtjpxy3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd4', + ), + }, + index: { account: 12 }, + }, + }, + }), + usdcValue: 0, + } satisfies AssetBalance; let useStore: UseBoundStore>; @@ -52,7 +73,7 @@ describe('Send Slice', () => { expect(txInProgress).toBeFalsy(); const { amountErr, recipientErr } = sendValidationErrors( - selectionExample.asset, + selectionExample, amount, recipient, memo, @@ -69,26 +90,26 @@ describe('Send Slice', () => { test('validate high enough amount validates', () => { const assetBalance = new Amount({ hi: 1n }); - useStore.getState().send.setSelection({ - ...selectionExample, - asset: { ...selectionExample.asset, amount: assetBalance }, + const state = produce(selectionExample, draft => { + draft.value.valueView.value!.amount = assetBalance; }); - useStore.getState().send.setAmount('1'); + useStore.getState().send.setSelection(state); + useStore.getState().send.setAmount('1000'); const { selection, amount } = useStore.getState().send; - const { amountErr } = sendValidationErrors(selection?.asset, amount, 'xyz', 'a memo'); + const { amountErr } = sendValidationErrors(selection, amount, 'xyz', 'a memo'); expect(amountErr).toBeFalsy(); }); test('validate error when too low the balance of the asset', () => { const assetBalance = new Amount({ lo: 2n }); - useStore.getState().send.setSelection({ - ...selectionExample, - asset: { ...selectionExample.asset, amount: assetBalance }, + const state = produce(selectionExample, draft => { + draft.value.valueView.value!.amount = assetBalance; }); + useStore.getState().send.setSelection(state); useStore.getState().send.setAmount('6'); const { selection, amount } = useStore.getState().send; - const { amountErr } = sendValidationErrors(selection?.asset, amount, 'xyz', 'a memo'); + const { amountErr } = sendValidationErrors(selection, amount, 'xyz', 'a memo'); expect(amountErr).toBeTruthy(); }); }); @@ -109,7 +130,7 @@ describe('Send Slice', () => { useStore.getState().send.setRecipient(rightAddress); expect(useStore.getState().send.recipient).toBe(rightAddress); const { selection, amount, recipient, memo } = useStore.getState().send; - const { recipientErr } = sendValidationErrors(selection?.asset, amount, recipient, memo); + const { recipientErr } = sendValidationErrors(selection, amount, recipient, memo); expect(recipientErr).toBeFalsy(); }); @@ -120,7 +141,7 @@ describe('Send Slice', () => { useStore.getState().send.setSelection(selectionExample); useStore.getState().send.setRecipient(badAddressLength); const { selection, amount, recipient, memo } = useStore.getState().send; - const { recipientErr } = sendValidationErrors(selection?.asset, amount, recipient, memo); + const { recipientErr } = sendValidationErrors(selection, amount, recipient, memo); expect(recipientErr).toBeTruthy(); }); @@ -131,14 +152,14 @@ describe('Send Slice', () => { useStore.getState().send.setSelection(selectionExample); useStore.getState().send.setRecipient(badAddressPrefix); const { selection, amount, recipient, memo } = useStore.getState().send; - const { recipientErr } = sendValidationErrors(selection?.asset, amount, recipient, memo); + const { recipientErr } = sendValidationErrors(selection, amount, recipient, memo); expect(recipientErr).toBeTruthy(); }); test('recipient will have a validation error after entering a very long memo', () => { useStore.getState().send.setMemo('b'.repeat(512)); const { selection, amount, recipient, memo } = useStore.getState().send; - const { memoErr } = sendValidationErrors(selection?.asset, amount, recipient, memo); + const { memoErr } = sendValidationErrors(selection, amount, recipient, memo); expect(memoErr).toBeTruthy(); }); }); diff --git a/apps/webapp/src/state/send.ts b/apps/webapp/src/state/send.ts index 15fe8daa8e..d4f481b3de 100644 --- a/apps/webapp/src/state/send.ts +++ b/apps/webapp/src/state/send.ts @@ -1,24 +1,33 @@ import { AllSlices, SliceCreator } from './index'; -import { fromBaseUnitAmountAndMetadata, isPenumbraAddr, toBaseUnit } from '@penumbra-zone/types'; +import { + address, + addressIndex, + assetId, + denomMetadata, + fromBaseUnitAmountAndMetadata, + getDisplayDenomExponent, + isPenumbraAddr, + toBaseUnit, + validateAndReturnOrThrow, +} 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'; import { errorTxToast, loadingTxToast, successTxToast } from '../components/shared/toast-content'; -import { AssetBalance } from '../fetchers/balances'; -import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; -import { Selection } from './types'; +import { AssetBalance } from '../fetchers/balances/balances.ts'; import { MemoPlaintext } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1alpha1/transaction_pb'; -import { getAddressByIndex } from '../fetchers/address'; -import { getTransactionPlan, planWitnessBuildBroadcast } from './helpers.ts'; -import { getDisplayDenomExponent } from '@penumbra-zone/types/src/denom-metadata.ts'; +import { getTransactionPlan, planWitnessBuildBroadcast } from './helpers'; + import { Fee, FeeTier_Tier, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1alpha1/fee_pb'; +import { z } from 'zod'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; export interface SendSlice { - selection: Selection | undefined; - setSelection: (selection: Selection) => void; + selection: AssetBalance | undefined; + setSelection: (selection: AssetBalance) => void; amount: string; setAmount: (amount: string) => void; recipient: string; @@ -72,7 +81,7 @@ export const createSendSlice = (): SliceCreator => (set, get) => { return; } - const txnPlanReq = await assembleRequest(get().send); + const txnPlanReq = assembleRequest(get().send); const plan = await getTransactionPlan(txnPlanReq); const fee = plan?.transactionParameters?.fee; if (!fee?.amount) return; @@ -94,7 +103,7 @@ export const createSendSlice = (): SliceCreator => (set, get) => { const { dismiss } = toastFn(loadingTxToast); try { - const req = await assembleRequest(get().send); + const req = assembleRequest(get().send); const txHash = await planWitnessBuildBroadcast(req); dismiss(); toastFn(successTxToast(txHash)); @@ -116,21 +125,46 @@ export const createSendSlice = (): SliceCreator => (set, get) => { }; }; -const assembleRequest = async ({ amount, feeTier, recipient, selection, memo }: SendSlice) => { - if (typeof selection?.accountIndex === 'undefined') throw new Error('no selected account'); - if (!selection.asset) throw new Error('no selected asset'); +const selectionSchema = z.object({ + value: z.object({ + valueView: z.object({ + case: z.literal('knownAssetId'), + value: z.object({ + metadata: denomMetadata.extend({ penumbraAssetId: assetId }).required(), + }), + }), + }), + + address: z.object({ + addressView: z.object({ + case: z.literal('decoded'), + value: z.object({ + address, + index: addressIndex, + }), + }), + }), +}); + +const assembleRequest = ({ amount, feeTier, recipient, selection, memo }: SendSlice) => { + const validatedSelection = validateAndReturnOrThrow(selectionSchema, selection); return new TransactionPlannerRequest({ outputs: [ { address: { altBech32m: recipient }, value: { - amount: toBaseUnit(BigNumber(amount), getDisplayDenomExponent(selection.asset.metadata)), - assetId: { inner: selection.asset.assetId.inner }, + amount: toBaseUnit( + BigNumber(amount), + getDisplayDenomExponent( + new Metadata(validatedSelection.value.valueView.value.metadata), + ), + ), + assetId: validatedSelection.value.valueView.value.metadata.penumbraAssetId, }, }, ], - source: new AddressIndex({ account: selection.accountIndex }), + source: validatedSelection.address.addressView.value.index, // Note: we currently don't provide a UI for setting the fee manually. Thus, // a `feeMode` of `manualFee` is not supported here. @@ -143,7 +177,7 @@ const assembleRequest = async ({ amount, feeTier, recipient, selection, memo }: }, memo: new MemoPlaintext({ - returnAddress: await getAddressByIndex(selection.accountIndex), + returnAddress: validatedSelection.address.addressView.value.address, text: memo, }), }); @@ -157,7 +191,9 @@ const validateAmount = ( */ amountInDisplayDenom: string, ): boolean => { - const balanceAmt = fromBaseUnitAmountAndMetadata(asset.amount, asset.metadata); + if (asset.value.valueView.case !== 'knownAssetId') throw new Error('unknown asset selected'); + + const balanceAmt = fromBaseUnitAmountAndMetadata(asset.value.valueView.value); return Boolean(amountInDisplayDenom) && BigNumber(amountInDisplayDenom).gt(balanceAmt); }; diff --git a/apps/webapp/src/state/swap.test.ts b/apps/webapp/src/state/swap.test.ts index 48ee879829..8ffa4c1182 100644 --- a/apps/webapp/src/state/swap.test.ts +++ b/apps/webapp/src/state/swap.test.ts @@ -1,32 +1,44 @@ import { create, StoreApi, UseBoundStore } from 'zustand'; import { AllSlices, initializeStore } from './index'; import { beforeEach, describe, expect, test } from 'vitest'; -import { AssetBalance } from '../fetchers/balances'; +import { AssetBalance } from '../fetchers/balances/balances.ts'; import { - AssetId, Metadata, + ValueView, } 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'; +import { bech32ToUint8Array } from '@penumbra-zone/types'; import { localAssets } from '@penumbra-zone/constants'; +import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; describe('Swap Slice', () => { - const assetBalance: AssetBalance = { - metadata: new Metadata({ - display: 'xyz', - denomUnits: [{ denom: 'xyz', exponent: 3 }], + const selectionExample = { + value: new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: new Amount({ + lo: 0n, + hi: 0n, + }), + metadata: new Metadata({ display: 'test_usd', denomUnits: [{ exponent: 18 }] }), + }, + }, }), - assetId: new AssetId({ inner: stringToUint8Array('abcdefg') }), - amount: new Amount(), - usdcValue: 1234, - }; - - const selection: Selection = { - address: 'address123', - accountIndex: 4, - asset: assetBalance, - }; + address: new AddressView({ + addressView: { + case: 'opaque', + value: { + address: { + inner: bech32ToUint8Array( + 'penumbra1e8k5cyds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uu0rgkvtjpxy3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd4', + ), + }, + }, + }, + }), + usdcValue: 0, + } satisfies AssetBalance; let useStore: UseBoundStore>; @@ -43,8 +55,8 @@ describe('Swap Slice', () => { test('assetIn can be set', () => { expect(useStore.getState().swap.assetIn).toBeUndefined(); - useStore.getState().swap.setAssetIn(selection); - expect(useStore.getState().swap.assetIn).toBe(selection); + useStore.getState().swap.setAssetIn(selectionExample); + expect(useStore.getState().swap.assetIn).toBe(selectionExample); }); test('assetOut can be set', () => { diff --git a/apps/webapp/src/state/swap.ts b/apps/webapp/src/state/swap.ts index 0666280a83..7c218b8958 100644 --- a/apps/webapp/src/state/swap.ts +++ b/apps/webapp/src/state/swap.ts @@ -1,19 +1,18 @@ import { AllSlices, SliceCreator } from './index'; -import { Selection } from './types'; import { errorTxToast, loadingTxToast, successTxToast } from '../components/shared/toast-content'; -import { toBaseUnit } from '@penumbra-zone/types'; import { toast } from '@penumbra-zone/ui/components/ui/use-toast'; import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1alpha1/view_pb'; -import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; -import { getAddressByIndex } from '../fetchers/address'; -import BigNumber from 'bignumber.js'; import { planWitnessBuildBroadcast } from './helpers'; import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { AssetBalance } from '../fetchers/balances/balances.ts'; +import { toBaseUnit } from '@penumbra-zone/types'; +import BigNumber from 'bignumber.js'; import { getDisplayDenomExponent } from '@penumbra-zone/types/src/denom-metadata'; +import { getAddressByIndex } from '../fetchers/address'; export interface SwapSlice { - assetIn: Selection | undefined; - setAssetIn: (selection: Selection) => void; + assetIn: AssetBalance | undefined; + setAssetIn: (asset: AssetBalance) => void; amount: string; setAmount: (amount: string) => void; assetOut: Metadata | undefined; @@ -74,28 +73,39 @@ export const createSwapSlice = (): SliceCreator => (set, get) => { }; const assembleRequest = async ({ assetIn, amount, assetOut }: SwapSlice) => { - if (assetIn?.accountIndex === undefined || !assetIn.asset || !assetOut?.penumbraAssetId) - throw new Error('missing selected tokens'); + if (assetIn?.value.valueView.case !== 'knownAssetId') throw new Error('unknown denom selected'); + if (!assetIn.value.valueView.value.metadata?.penumbraAssetId) + throw new Error('missing metadata for assetIn'); + if (assetIn.address.addressView.case !== 'decoded') + throw new Error('address in view is not decoded'); + if (!assetIn.address.addressView.value.index) throw new Error('No index for assetIn address'); + if (assetOut?.penumbraAssetId === undefined) throw new Error('assetOut has no asset id'); return new TransactionPlannerRequest({ swaps: [ { targetAsset: assetOut.penumbraAssetId, value: { - amount: toBaseUnit(BigNumber(amount), getDisplayDenomExponent(assetIn.asset.metadata)), - assetId: assetIn.asset.assetId, + amount: toBaseUnit( + BigNumber(amount), + getDisplayDenomExponent(assetIn.value.valueView.value.metadata), + ), + assetId: assetIn.value.valueView.value.metadata.penumbraAssetId, }, - claimAddress: await getAddressByIndex(assetIn.accountIndex), + claimAddress: await getAddressByIndex(assetIn.address.addressView.value.index.account), // TODO: Calculate this properly in subsequent PR // Asset Id should almost certainly be upenumbra, // may need to indicate native denom in registry fee: { - amount: toBaseUnit(BigNumber(amount), getDisplayDenomExponent(assetIn.asset.metadata)), - assetId: assetIn.asset.assetId, + amount: toBaseUnit( + BigNumber(amount), + getDisplayDenomExponent(assetIn.value.valueView.value.metadata), + ), + assetId: assetIn.value.valueView.value.metadata.penumbraAssetId, }, }, ], - source: new AddressIndex({ account: assetIn.accountIndex }), + source: assetIn.address.addressView.value.index, }); }; diff --git a/apps/webapp/src/state/types.ts b/apps/webapp/src/state/types.ts deleted file mode 100644 index c830430e9c..0000000000 --- a/apps/webapp/src/state/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AssetBalance } from '../fetchers/balances'; - -export interface Selection { - address: string | undefined; - accountIndex: number | undefined; - asset: AssetBalance | undefined; -} diff --git a/package.json b/package.json index a6de5a3a4a..a8b474dcba 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "clean": "turbo clean", "format": "prettier --write .", "format-check": "prettier --check .", - "all-check": "pnpm install && pnpm format && pnpm format-check && pnpm lint && pnpm test && pnpm build" + "all-check": "pnpm install && pnpm format && pnpm lint && pnpm test && pnpm build" }, "dependencies": { "@buf/cosmos_ibc.bufbuild_es": "1.7.2-20240123184419-30c7be3837b0.1", diff --git a/packages/router/src/grpc/view-protocol-server/asset-metadata-by-id.ts b/packages/router/src/grpc/view-protocol-server/asset-metadata-by-id.ts new file mode 100644 index 0000000000..e422b394d1 --- /dev/null +++ b/packages/router/src/grpc/view-protocol-server/asset-metadata-by-id.ts @@ -0,0 +1,31 @@ +import type { Impl } from '.'; +import { servicesCtx } from '../../ctx'; +import { IndexedDbInterface, RootQuerierInterface } from '@penumbra-zone/types'; +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; + +export const assetMetadataById: Impl['assetMetadataById'] = async (req, ctx) => { + if (!req.assetId) throw new Error('No asset id passed in request'); + + const services = ctx.values.get(servicesCtx); + const { indexedDb, querier } = await services.getWalletServices(); + const denomMetadata = await getAssetMetadata(req.assetId, indexedDb, querier); + + return { denomMetadata }; +}; + +export const getAssetMetadata = async ( + targetAsset: AssetId, + indexedDb: IndexedDbInterface, + querier: RootQuerierInterface, +) => { + // First, try to get the metadata from the internal database. + const localMetadata = await indexedDb.getAssetsMetadata(targetAsset); + if (localMetadata) return localMetadata; + + // If not available locally, query the metadata from the node. + const nodeMetadata = await querier.shieldedPool.assetMetadata(targetAsset); + if (nodeMetadata) return nodeMetadata; + + // If the metadata is not found, throw an error with details about the asset. + throw new Error(`No denom metadata found for asset: ${JSON.stringify(targetAsset.toJson())}`); +}; diff --git a/packages/router/src/grpc/view-protocol-server/index.ts b/packages/router/src/grpc/view-protocol-server/index.ts index 0dd31fb78c..d0eab1c002 100644 --- a/packages/router/src/grpc/view-protocol-server/index.ts +++ b/packages/router/src/grpc/view-protocol-server/index.ts @@ -1,11 +1,13 @@ import type { ViewService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/view/v1alpha1/view_connect'; import type { ServiceImpl } from '@connectrpc/connect'; + import { addressByIndex } from './address-by-index'; import { appParameters } from './app-parameters'; import { assets } from './assets'; import { authorizeAndBuild } from './authorize-and-build'; import { balances } from './balances'; import { broadcastTransaction } from './broadcast-transaction'; +import { assetMetadataById } from './asset-metadata-by-id'; import { ephemeralAddress } from './ephemeral-address'; import { fMDParameters } from './fmd-parameters'; import { gasPrices } from './gas-prices'; @@ -28,13 +30,14 @@ import { witnessAndBuild } from './witness-and-build'; export type Impl = ServiceImpl; -export const viewImpl: Omit = { +export const viewImpl: Impl = { addressByIndex, appParameters, assets, authorizeAndBuild, balances, broadcastTransaction, + assetMetadataById, ephemeralAddress, fMDParameters, gasPrices, diff --git a/packages/router/src/grpc/view-protocol-server/transaction-planner.ts b/packages/router/src/grpc/view-protocol-server/transaction-planner.ts index 80ae8b862e..49787d702a 100644 --- a/packages/router/src/grpc/view-protocol-server/transaction-planner.ts +++ b/packages/router/src/grpc/view-protocol-server/transaction-planner.ts @@ -1,33 +1,12 @@ import type { Impl } from '.'; import { servicesCtx } from '../../ctx'; - import { getAddressByIndex, TxPlanner } from '@penumbra-zone/wasm-ts'; - import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; - import { Code, ConnectError } from '@connectrpc/connect'; -import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; -import { IndexedDbInterface, RootQuerierInterface } from '@penumbra-zone/types'; +import { getAssetMetadata } from './asset-metadata-by-id'; import { gasPrices } from './gas-prices'; import { GasPrices } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1alpha1/fee_pb'; -async function getAssetMetadata( - indexedDb: IndexedDbInterface, - targetAsset: AssetId, - querier: RootQuerierInterface, -) { - // First, try to get the metadata from the internal database. - const localMetadata = await indexedDb.getAssetsMetadata(targetAsset); - if (localMetadata) return localMetadata; - - // If not available locally, query the metadata from the node. - const nodeMetadata = await querier.shieldedPool.assetMetadata(targetAsset); - if (nodeMetadata) return nodeMetadata; - - // If the metadata is not found, throw an error with details about the asset. - throw new Error(`No denom metadata found for asset: ${JSON.stringify(targetAsset.toJson())}`); -} - export const transactionPlanner: Impl['transactionPlanner'] = async (req, ctx) => { const services = ctx.values.get(servicesCtx); const { @@ -68,8 +47,8 @@ export const transactionPlanner: Impl['transactionPlanner'] = async (req, ctx) = if (!value || !targetAsset || !fee || !claimAddress) throw new Error('no value, targetAsset, fee or claimAddress in swap'); - const intoDenomMetadata = await getAssetMetadata(indexedDb, targetAsset, querier); - planner.swap(value, intoDenomMetadata, fee, claimAddress); + const intoAssetMetadata = await getAssetMetadata(targetAsset, indexedDb, querier); + planner.swap(value, intoAssetMetadata, fee, claimAddress); } for (const { swapCommitment } of req.swapClaims) { diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index a5c4543606..efe11b0f3b 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -16,7 +16,6 @@ "strictNullChecks": true, "allowUnreachableCode": false, "allowUnusedLabels": false, - "exactOptionalPropertyTypes": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, diff --git a/packages/types/src/account.ts b/packages/types/src/account.ts deleted file mode 100644 index deaeb23404..0000000000 --- a/packages/types/src/account.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Account { - address: string; - index: number; -} diff --git a/packages/types/src/amount.test.ts b/packages/types/src/amount.test.ts index d2853b2498..11563d2aeb 100644 --- a/packages/types/src/amount.test.ts +++ b/packages/types/src/amount.test.ts @@ -8,7 +8,10 @@ import { joinLoHiAmount, } from './amount'; import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1alpha1/num_pb'; -import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { + Metadata, + ValueView_KnownAssetId, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; describe('lohi helpers', () => { it('fromBaseUnitAmount works', () => { @@ -42,8 +45,10 @@ describe('lohi helpers', () => { }); const result = fromBaseUnitAmountAndMetadata( - new Amount({ lo: 123456789n, hi: 0n }), - penumbraMetadata, + new ValueView_KnownAssetId({ + amount: { lo: 123456789n, hi: 0n }, + metadata: penumbraMetadata, + }), ); expect(result.toString()).toBe('123.456789'); diff --git a/packages/types/src/amount.ts b/packages/types/src/amount.ts index b7dcc37ec1..4100f7a27b 100644 --- a/packages/types/src/amount.ts +++ b/packages/types/src/amount.ts @@ -1,7 +1,7 @@ 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 { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; +import { ValueView_KnownAssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1alpha1/asset_pb'; import { getDisplayDenomExponent } from './denom-metadata'; export const joinLoHiAmount = (amount: Amount): bigint => { @@ -12,7 +12,13 @@ export const fromBaseUnitAmount = (amount: Amount, exponent = 0): BigNumber => { return fromBaseUnit(amount.lo, amount.hi, exponent); }; -export const fromBaseUnitAmountAndMetadata = (amount: Amount, metadata: Metadata): BigNumber => { +export const fromBaseUnitAmountAndMetadata = ({ + amount, + metadata, +}: ValueView_KnownAssetId): BigNumber => { + if (!amount) throw new Error('No amount in value view'); + if (!metadata) throw new Error('No denom in value view'); + return fromBaseUnitAmount(amount, getDisplayDenomExponent(metadata)); }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d6dc485816..5e9463e48a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -20,5 +20,7 @@ export * from './querier'; export * from './std-route'; export * from './amount'; export * from './chain'; -export * from './account'; +export * from './denom-metadata'; export * from './jsonified'; +export * from './schemas'; +export * from './type-predicates'; diff --git a/packages/types/src/schemas/address-index.ts b/packages/types/src/schemas/address-index.ts new file mode 100644 index 0000000000..d632898f71 --- /dev/null +++ b/packages/types/src/schemas/address-index.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const addressIndex = z.object({ + account: z.number().int(), + randomizer: z.instanceof(Uint8Array), +}); diff --git a/packages/types/src/schemas/address.ts b/packages/types/src/schemas/address.ts new file mode 100644 index 0000000000..8647e9de4a --- /dev/null +++ b/packages/types/src/schemas/address.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const address = z.object({ + inner: z.instanceof(Uint8Array), + altBech32m: z.string(), +}); diff --git a/packages/types/src/schemas/asset-id.ts b/packages/types/src/schemas/asset-id.ts new file mode 100644 index 0000000000..d681178319 --- /dev/null +++ b/packages/types/src/schemas/asset-id.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const assetId = z.object({ + inner: z.instanceof(Uint8Array), + altBech32m: z.string(), + altBaseDenom: z.string(), +}); diff --git a/packages/types/src/schemas/asset-image--theme.ts b/packages/types/src/schemas/asset-image--theme.ts new file mode 100644 index 0000000000..cf700877b2 --- /dev/null +++ b/packages/types/src/schemas/asset-image--theme.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const assetImage_Theme = z.object({ + primaryColorHex: z.string(), + circle: z.boolean(), + darkMode: z.boolean(), +}); diff --git a/packages/types/src/schemas/asset-image.ts b/packages/types/src/schemas/asset-image.ts new file mode 100644 index 0000000000..bb370dd17d --- /dev/null +++ b/packages/types/src/schemas/asset-image.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { assetImage_Theme } from './asset-image--theme'; + +export const assetImage = z.object({ + png: z.string(), + svg: z.string(), + theme: assetImage_Theme.optional(), +}); diff --git a/packages/types/src/schemas/denom-metadata.ts b/packages/types/src/schemas/denom-metadata.ts new file mode 100644 index 0000000000..be5ab9d165 --- /dev/null +++ b/packages/types/src/schemas/denom-metadata.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { denomUnit } from './denom-unit'; +import { assetId } from './asset-id'; +import { assetImage } from './asset-image'; + +export const denomMetadata = z.object({ + description: z.string(), + denomUnits: z.array(denomUnit), + base: z.string(), + display: z.string(), + name: z.string(), + symbol: z.string(), + penumbraAssetId: assetId.optional(), + images: z.array(assetImage), +}); diff --git a/packages/types/src/schemas/denom-unit.ts b/packages/types/src/schemas/denom-unit.ts new file mode 100644 index 0000000000..41d3b02d3c --- /dev/null +++ b/packages/types/src/schemas/denom-unit.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const denomUnit = z.object({ + denom: z.string(), + exponent: z.number().int(), + aliases: z.array(z.string()), +}); diff --git a/packages/types/src/schemas/index.ts b/packages/types/src/schemas/index.ts new file mode 100644 index 0000000000..a21cbc0fb1 --- /dev/null +++ b/packages/types/src/schemas/index.ts @@ -0,0 +1,7 @@ +export * from './address'; +export * from './address-index'; +export * from './asset-id'; +export * from './asset-image'; +export * from './asset-image--theme'; +export * from './denom-metadata'; +export * from './denom-unit'; diff --git a/packages/types/src/type-predicates/address-view.ts b/packages/types/src/type-predicates/address-view.ts new file mode 100644 index 0000000000..39ae064485 --- /dev/null +++ b/packages/types/src/type-predicates/address-view.ts @@ -0,0 +1,25 @@ +import { address } from '../schemas/address'; +import { isType } from '../validation'; +import { z } from 'zod'; + +export const hasAccountIndex = isType( + z.object({ + addressView: z.object({ + case: z.literal('decoded'), + value: z.object({ + index: z.object({ + account: z.number(), + }), + }), + }), + }), +); + +export const hasAddress = isType( + z.object({ + addressView: z.object({ + case: z.enum(['decoded', 'opaque']), + value: z.object({ address }), + }), + }), +); diff --git a/packages/types/src/type-predicates/index.ts b/packages/types/src/type-predicates/index.ts new file mode 100644 index 0000000000..ffec9bd128 --- /dev/null +++ b/packages/types/src/type-predicates/index.ts @@ -0,0 +1,2 @@ +export * from './address-view'; +export * from './value-view'; diff --git a/packages/types/src/type-predicates/value-view.ts b/packages/types/src/type-predicates/value-view.ts new file mode 100644 index 0000000000..f26cc0d272 --- /dev/null +++ b/packages/types/src/type-predicates/value-view.ts @@ -0,0 +1,14 @@ +import { denomMetadata } from '../schemas/denom-metadata'; +import { isType } from '../validation'; +import { z } from 'zod'; + +export const hasDenomMetadata = isType( + z.object({ + valueView: z.object({ + case: z.literal('knownAssetId'), + value: z.object({ + metadata: denomMetadata, + }), + }), + }), +); diff --git a/packages/types/src/validation.test.ts b/packages/types/src/validation.test.ts index fe7214e183..95a53ac583 100644 --- a/packages/types/src/validation.test.ts +++ b/packages/types/src/validation.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { z } from 'zod'; -import { validateSchema } from './validation'; +import { isType, validateSchema } from './validation'; describe('validation', () => { describe('validateSchema()', () => { @@ -98,4 +98,33 @@ describe('validation', () => { expect(mockLogger).toHaveBeenCalledOnce(); }); }); + + describe("isType()'s returned type predicate function", () => { + interface Person { + name: string; + age?: number; + } + + const personWithAgeSchema = z.object({ + name: z.string(), + age: z.number(), + }); + + it('returns `true` if the passed-in value matches the schema', () => { + const matchingPerson: Person = { + name: 'Ada Lovelace', + age: 30, + }; + + expect(isType(personWithAgeSchema)(matchingPerson)).toBe(true); + }); + + it('returns `false` if the passed-in value does not match the schema', () => { + const nonMatchingPerson: Person = { + name: 'Ada Lovelace', + }; + + expect(isType(personWithAgeSchema)(nonMatchingPerson)).toBe(false); + }); + }); }); diff --git a/packages/types/src/validation.ts b/packages/types/src/validation.ts index 8a2055c8c9..64caad1491 100644 --- a/packages/types/src/validation.ts +++ b/packages/types/src/validation.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { ZodTypeAny, z } from 'zod'; import { isDevEnv } from './environment'; // In production, we do not want to throw validation errors, but log them. @@ -18,3 +18,52 @@ export const validateSchema = (schema: z.ZodSchema, data: unknown): T => { } } }; + +/** + * Like `validateSchema`, this validates `data` and then returns it if valid. + * _Unlike_ `validateSchema`, this function throws the validation error message + * if validation fails. Useful for when you need type safety of the value you're + * validating, and intend to show an error when validation fails. + */ +export const validateAndReturnOrThrow = (schema: z.ZodSchema, data: unknown): T => { + const result = schema.safeParse(data); + + if (result.success) { + return result.data; + } else { + throw new Error(result.error.message); + } +}; + +/** + * Returns a type predicate that allows safe property access. + * + * @example + * ```TS + * const visibleAddressViewWithAccountIndexSchema = z.object({ + * addressView: z.object({ + * case: z.literal('visible'), + * value: z.object({ + * index: z.object({ + * account: z.number(), + * }), + * }), + * }), + * }); + * + * const addressView = new AddressView(); + * + * const hasAccountIndex = isType(visibleAddressViewWithAccountIndexSchema); + * + * if (hasAccountIndex(addressView)) { + * // No need for `?`, `!`, or `case === 'visible'`. + * console.log(addressView.addressView.value.index.account); + * } + * ```` + * + * @see https://github.com/colinhacks/zod/issues/2345 + */ +export const isType = + (schema: T) => + (data: unknown): data is z.infer => + schema.safeParse(data).success; diff --git a/packages/ui/components/ui/address-component.tsx b/packages/ui/components/ui/address-component.tsx new file mode 100644 index 0000000000..c3db089edc --- /dev/null +++ b/packages/ui/components/ui/address-component.tsx @@ -0,0 +1,21 @@ +import { bech32Address, shortenAddress } from '@penumbra-zone/types'; +import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; + +interface AddressComponentProps { + address: Address; + ephemeral?: boolean; +} + +/** + * Displays an address. The address is truncated to the prefix plus 24 + * characters, and rendered in color when it is ephemeral. + */ +export const AddressComponent = ({ address, ephemeral }: AddressComponentProps) => { + const bech32Addr = bech32Address(address); + + return ( + + {shortenAddress(bech32Addr)} + + ); +}; diff --git a/packages/ui/components/ui/address-icon.tsx b/packages/ui/components/ui/address-icon.tsx index e854895e5d..74280e7991 100644 --- a/packages/ui/components/ui/address-icon.tsx +++ b/packages/ui/components/ui/address-icon.tsx @@ -1,8 +1,20 @@ import { Identicon } from './identicon'; +import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; +import { bech32Address } from '@penumbra-zone/types'; + +interface AddressIconProps { + address: Address; + size: number; +} /** * A simple component to display a consistently styled icon for a given address. */ -export const AddressIcon = ({ address, size }: { address: string; size: number }) => ( - +export const AddressIcon = ({ address, size }: AddressIconProps) => ( + ); diff --git a/packages/ui/components/ui/address.test.tsx b/packages/ui/components/ui/address.test.tsx index a504f5190c..e6007be9d9 100644 --- a/packages/ui/components/ui/address.test.tsx +++ b/packages/ui/components/ui/address.test.tsx @@ -1,26 +1,28 @@ -import { Address } from './address'; +import { AddressComponent } from './address-component'; import { describe, expect, test } from 'vitest'; import { render } from '@testing-library/react'; -import { shortenAddress } from '@penumbra-zone/types'; +import { bech32ToUint8Array, shortenAddress } from '@penumbra-zone/types'; +import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; describe('
', () => { const address = - 'penumbra1nzxd3wqautgr8hmwwvvgwnsgtpnl3rexwyj0srpcyaf75968mq3wseftwzwcsx458la09m3am7x2qlk7jfchuvpv6uvw79ctzpnrt7c2wd6n9j9xgk8regdwkrjple9uh2tekf'; + 'penumbra1u7dk4qw6fz3vlwyjl88vlj6gqv4hcmz2vesm87t7rm0lvwmgqqkrp3zrdmfg6et86ggv4nwmnc8vy39uxyacwm8g7trk77ad0c8n4qt76ncvuukx6xlj8mskhyjpn4twkpwwl2'; + const pbAddress = new Address({ inner: bech32ToUint8Array(address) }); test('renders the shortened address', () => { - const { baseElement } = render(
); + const { baseElement } = render(); expect(baseElement).toHaveTextContent(shortenAddress(address)); }); test('uses text-muted-foreground for non-ephemeral addresses', () => { - const { getByText } = render(
); + const { getByText } = render(); expect(getByText(shortenAddress(address))).toHaveClass('text-muted-foreground'); }); test('uses colored text for ephemeral addresses', () => { - const { getByText } = render(
); + const { getByText } = render(); expect(getByText(shortenAddress(address))).toHaveClass('text-[#8D5728]'); }); diff --git a/packages/ui/components/ui/address.tsx b/packages/ui/components/ui/address.tsx index d68a0c3099..e69de29bb2 100644 --- a/packages/ui/components/ui/address.tsx +++ b/packages/ui/components/ui/address.tsx @@ -1,17 +0,0 @@ -import { shortenAddress } from '@penumbra-zone/types'; - -/** - * Displays an address. The address is truncated to the prefix plus 24 - * characters, and rendered in color when it is ephemeral. - */ -export const Address = ({ - address, - ephemeral = false, -}: { - address: string; - ephemeral?: boolean; -}) => ( - - {shortenAddress(address)} - -); diff --git a/packages/ui/components/ui/identicon/index.tsx b/packages/ui/components/ui/identicon/index.tsx index 8c9dd9def7..f93fbe7525 100644 --- a/packages/ui/components/ui/identicon/index.tsx +++ b/packages/ui/components/ui/identicon/index.tsx @@ -7,9 +7,9 @@ export const Identicon = ({ type, ...props }: IdenticonProps & { type: 'gradient return ; }; -const IdenticonGradient = ({ name, size = 120, className }: IdenticonProps) => { - const gradient = useMemo(() => generateGradient(name), [name]); - const gradientId = useMemo(() => `gradient-${name}`, [name]); +const IdenticonGradient = ({ uniqueIdentifier, size = 120, className }: IdenticonProps) => { + const gradient = useMemo(() => generateGradient(uniqueIdentifier), [uniqueIdentifier]); + const gradientId = useMemo(() => `gradient-${uniqueIdentifier}`, [uniqueIdentifier]); return ( { ); }; -const IdenticonSolid = ({ name, size = 120, className }: IdenticonProps) => { - const color = useMemo(() => generateSolidColor(name), [name]); +const IdenticonSolid = ({ uniqueIdentifier, size = 120, className }: IdenticonProps) => { + const color = useMemo(() => generateSolidColor(uniqueIdentifier), [uniqueIdentifier]); return ( { dy='.3em' className='uppercase' > - {name[0]} + {uniqueIdentifier[0]} ); diff --git a/packages/ui/components/ui/identicon/types.ts b/packages/ui/components/ui/identicon/types.ts index 6962c6ff74..248ecd8c2b 100644 --- a/packages/ui/components/ui/identicon/types.ts +++ b/packages/ui/components/ui/identicon/types.ts @@ -1,5 +1,5 @@ export interface IdenticonProps { - name: string; + uniqueIdentifier: string; size?: number; className?: string; } diff --git a/packages/ui/components/ui/select-account.tsx b/packages/ui/components/ui/select-account.tsx index c9f9e22ac5..93ba07560d 100644 --- a/packages/ui/components/ui/select-account.tsx +++ b/packages/ui/components/ui/select-account.tsx @@ -1,5 +1,3 @@ -import { Account } from '@penumbra-zone/types'; -import { Address } from './address'; import { AddressIcon } from './address-icon'; import { ArrowLeftIcon, ArrowRightIcon, InfoIcon } from 'lucide-react'; import { cn } from '../../lib/utils'; @@ -10,45 +8,49 @@ import { Input } from './input'; import { Switch } from './switch'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'; import { useEffect, useState } from 'react'; +import { AddressComponent } from './address-component'; +import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; +import { bech32Address } from '@penumbra-zone/types'; interface SelectAccountProps { - getAccount: (index: number, ephemeral: boolean) => Promise | Account | undefined; + getAddrByIndex: (index: number, ephemeral: boolean) => Promise
| Address; } + const MAX_INDEX = 2 ** 32; -export const SelectAccount = ({ getAccount }: SelectAccountProps) => { +export const SelectAccount = ({ getAddrByIndex }: SelectAccountProps) => { const [index, setIndex] = useState(0); const [ephemeral, setEphemeral] = useState(false); const [width, setWidth] = useState(index.toString().length); - const [account, setAccount] = useState(); + const [address, setAddress] = useState
(); useEffect(() => { void (async () => { - const account = await getAccount(index, ephemeral); - setAccount(account); + const address = await getAddrByIndex(index, ephemeral); + setAddress(address); })(); - // getAccount updates the address every block + // getAddrByIndex updates the address every block // eslint-disable-next-line react-hooks/exhaustive-deps }, [index, ephemeral]); return ( <> - {!account ? ( + {!address ? ( <> ) : (
@@ -84,13 +86,13 @@ export const SelectAccount = ({ getAccount }: SelectAccountProps) => {
- +

-

+

- +
diff --git a/packages/ui/components/ui/tx/view/address-view.tsx b/packages/ui/components/ui/tx/view/address-view.tsx index b3edb8da08..6eeb862105 100644 --- a/packages/ui/components/ui/tx/view/address-view.tsx +++ b/packages/ui/components/ui/tx/view/address-view.tsx @@ -1,8 +1,8 @@ -import { Address } from '../../address'; import { AddressIcon } from '../../address-icon'; import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1alpha1/keys_pb'; import { bech32Address } from '@penumbra-zone/types'; import { CopyToClipboardIconButton } from '../../copy-to-clipboard-icon-button'; +import { AddressComponent } from '../../address-component'; interface AddressViewProps { view: AddressView | undefined; @@ -31,15 +31,14 @@ export const AddressViewComponent = ({ view, copyable = true }: AddressViewProps
{accountIndex !== undefined ? ( <> - - + {addressIndexLabel} {accountIndex} ) : ( -
+ )} {copyable && } diff --git a/packages/ui/components/ui/tx/view/value.tsx b/packages/ui/components/ui/tx/view/value.tsx index 3196d17701..51c82bb0e1 100644 --- a/packages/ui/components/ui/tx/view/value.tsx +++ b/packages/ui/components/ui/tx/view/value.tsx @@ -7,15 +7,16 @@ import { getDisplayDenomExponent } from '@penumbra-zone/types/src/denom-metadata interface ValueViewProps { view: ValueView | undefined; + showDenom?: boolean; } -export const ValueViewComponent = ({ view }: ValueViewProps) => { +export const ValueViewComponent = ({ view, showDenom = true }: ValueViewProps) => { if (!view) return <>; if (view.valueView.case === 'unknownAssetId') { const value = view.valueView.value; const amount = value.amount ?? new Amount(); - const encodedAssetId = bech32AssetId(value.assetId!); + const encodedAssetId = getDisplayDenomFromView(view); return (

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

@@ -36,15 +37,34 @@ export const ValueViewComponent = ({ view }: ValueViewProps) => { if (view.valueView.case === 'knownAssetId') { const value = view.valueView.value; const amount = value.amount ?? new Amount(); - const display_denom = value.metadata?.display ?? ''; + const displayDenom = getDisplayDenomFromView(view); const exponent = value.metadata ? getDisplayDenomExponent(value.metadata) : 0; return (
- {fromBaseUnitAmount(amount, exponent).toFormat()} {display_denom} + {fromBaseUnitAmount(amount, exponent).toFormat()} {showDenom && displayDenom}
); } return <>; }; + +export const getDisplayDenomFromView = (view: ValueView) => { + if (view.valueView.case === 'unknownAssetId') { + if (!view.valueView.value.assetId) throw new Error('no asset id for unknown denom'); + return bech32AssetId(view.valueView.value.assetId); + } + + if (view.valueView.case === 'knownAssetId') { + const displayDenom = view.valueView.value.metadata?.display; + if (displayDenom) return displayDenom; + + const assetId = view.valueView.value.metadata?.penumbraAssetId; + if (assetId) return bech32AssetId(assetId); + + return 'unknown'; + } + + throw new Error(`unexpected case ${view.valueView.case}`); +}; diff --git a/packages/ui/components/ui/types/address.tsx b/packages/ui/components/ui/types/address.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/ui/components/ui/types/value.tsx b/packages/ui/components/ui/types/value.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/ui/package.json b/packages/ui/package.json index c765a9ae44..c636d81914 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -24,7 +24,7 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", - "@testing-library/jest-dom": "^6.4.1", + "@testing-library/jest-dom": "^6.4.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "djb2a": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34e950d5ab..fc56a3535b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,9 @@ importers: bignumber.js: specifier: ^9.1.2 version: 9.1.2 + immer: + specifier: ^10.0.3 + version: 10.0.3 react: specifier: ^18.2.0 version: 18.2.0 @@ -238,6 +241,9 @@ importers: tailwindcss: specifier: ^3.4.1 version: 3.4.1 + zod: + specifier: ^3.22.4 + version: 3.22.4 zustand: specifier: ^4.5.0 version: 4.5.0(@types/react@18.2.53)(immer@10.0.3)(react@18.2.0) @@ -501,8 +507,8 @@ importers: specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.18)(@types/react@18.2.53)(react-dom@18.2.0)(react@18.2.0) '@testing-library/jest-dom': - specifier: ^6.4.1 - version: 6.4.1(vitest@1.2.2) + specifier: ^6.4.2 + version: 6.4.2(vitest@1.2.2) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -5248,8 +5254,8 @@ packages: pretty-format: 27.5.1 dev: true - /@testing-library/jest-dom@6.4.1(vitest@1.2.2): - resolution: {integrity: sha512-Z7qMM3J2Zw5H/nC2/5CYx5YcuaD56JmDFKNIozZ89VIo6o6Y9FMhssics4e2madEKYDNEpZz3+glPGz0yWMOag==} + /@testing-library/jest-dom@6.4.2(vitest@1.2.2): + resolution: {integrity: sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} peerDependencies: '@jest/globals': '>= 28'