diff --git a/.changeset/friendly-horses-hear.md b/.changeset/friendly-horses-hear.md new file mode 100644 index 0000000000..1f7a5a1897 --- /dev/null +++ b/.changeset/friendly-horses-hear.md @@ -0,0 +1,14 @@ +--- +'minifront': minor +'@repo/ui': patch +--- + +Minifront: + +- extend `BalanceSelector` to show not only assets with balances but all available assets +- fix the issues with empty wallets not rendering a swap block correctly +- reduce the height of `BalanceSelecor` and `AssetSelector` to `90dvh` +- autofocus the search inputs in `BalanceSelecor` and `AssetSelector` +- change validations of the swap input to allow entering any possible values + +UI: allow passing `autoFocus` attribute to the `IconInput` component diff --git a/apps/minifront/package.json b/apps/minifront/package.json index 7e5aae1fa5..5689a3a4e5 100644 --- a/apps/minifront/package.json +++ b/apps/minifront/package.json @@ -63,6 +63,7 @@ "@types/lodash": "^4.17.4", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", - "@types/react-helmet": "^6.1.11" + "@types/react-helmet": "^6.1.11", + "vite": "^5.2.11" } } diff --git a/apps/minifront/src/components/shared/balance-selector.tsx b/apps/minifront/src/components/shared/balance-selector.tsx deleted file mode 100644 index 3488bc1d4e..0000000000 --- a/apps/minifront/src/components/shared/balance-selector.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; -import { useId, useState } from 'react'; -import { IconInput } from '@repo/ui/components/ui/icon-input'; -import { Dialog, DialogClose, DialogContent, DialogHeader } from '@repo/ui/components/ui/dialog'; -import { cn } from '@repo/ui/lib/utils'; -import { ValueViewComponent } from '@repo/ui/components/ui/tx/view/value'; -import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; -import { getAddressIndex } from '@penumbra-zone/getters/address-view'; -import { getDisplayDenomFromView, getSymbolFromValueView } from '@penumbra-zone/getters/value-view'; -import { Box } from '@repo/ui/components/ui/box'; -import { motion } from 'framer-motion'; -import { isKnown } from '../swap/helpers'; - -const bySearch = (search: string) => (balancesResponse: BalancesResponse) => - isKnown(balancesResponse) && - (getDisplayDenomFromView(balancesResponse.balanceView) - .toLocaleLowerCase() - .includes(search.toLocaleLowerCase()) || - getSymbolFromValueView(balancesResponse.balanceView) - .toLocaleLowerCase() - .includes(search.toLocaleLowerCase())); - -interface BalanceSelectorProps { - value: BalancesResponse | undefined; - onChange: (selection: BalancesResponse) => void; - balances: BalancesResponse[]; -} - -/** - * Renders balances the user holds, and allows the user to select one. This is - * useful for a form where the user wants to send/sell/swap an asset that they - * already hold. - * - * Use `` if you want to render assets that aren't tied to any - * balance. - */ -export default function BalanceSelector({ value, balances, onChange }: BalanceSelectorProps) { - const [search, setSearch] = useState(''); - const [isOpen, setIsOpen] = useState(false); - const filteredBalances = search ? balances.filter(bySearch(search)) : balances; - const layoutId = useId(); - - return ( - <> - {!isOpen && ( - setIsOpen(true)} - > - - - )} - - {isOpen && ( - <> - {/* 0-opacity placeholder for layout's sake */} -
- -
- - )} - - - -
- Select asset -
- - } - value={search} - onChange={setSearch} - placeholder='Search assets...' - /> - -
-

Account

-

Asset

-
-
- {filteredBalances.map((b, i) => { - const index = getAddressIndex(b.accountAddress).account; - - return ( -
- -
onChange(b)} - > -

{index}

-
- -
-
-
-
- ); - })} -
-
-
-
-
- - ); -} diff --git a/apps/minifront/src/components/shared/input-token.tsx b/apps/minifront/src/components/shared/input-token.tsx index 851cde1feb..9ef1cc197e 100644 --- a/apps/minifront/src/components/shared/input-token.tsx +++ b/apps/minifront/src/components/shared/input-token.tsx @@ -1,6 +1,6 @@ import { Input } from '@repo/ui/components/ui/input'; import { cn } from '@repo/ui/lib/utils'; -import BalanceSelector from './balance-selector'; +import BalanceSelector from './selectors/balance-selector'; import { Validation } from './validation-result'; import { InputBlock } from './input-block'; import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; diff --git a/apps/minifront/src/components/shared/asset-selector.tsx b/apps/minifront/src/components/shared/selectors/asset-selector.tsx similarity index 93% rename from apps/minifront/src/components/shared/asset-selector.tsx rename to apps/minifront/src/components/shared/selectors/asset-selector.tsx index 4cb7d654af..ef51d763de 100644 --- a/apps/minifront/src/components/shared/asset-selector.tsx +++ b/apps/minifront/src/components/shared/selectors/asset-selector.tsx @@ -10,6 +10,7 @@ import { IconInput } from '@repo/ui/components/ui/icon-input'; import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { Box } from '@repo/ui/components/ui/box'; import { motion } from 'framer-motion'; +import { metadataBySearch } from './search-filters'; interface AssetSelectorProps { assets: Metadata[]; @@ -53,7 +54,7 @@ const useFilteredAssets = ({ assets, value, onChange, filter }: AssetSelectorPro const [search, setSearch] = useState(''); let filteredAssets = filter ? sortedAssets.filter(filter) : sortedAssets; - filteredAssets = search ? assets.filter(bySearch(search)) : assets; + filteredAssets = search ? assets.filter(metadataBySearch(search)) : assets; useEffect( () => switchAssetIfNecessary({ value, onChange, filter, assets: filteredAssets }), @@ -63,10 +64,6 @@ const useFilteredAssets = ({ assets, value, onChange, filter }: AssetSelectorPro return { filteredAssets, search, setSearch }; }; -const bySearch = (search: string) => (asset: Metadata) => - asset.display.toLocaleLowerCase().includes(search.toLocaleLowerCase()) || - asset.symbol.toLocaleLowerCase().includes(search.toLocaleLowerCase()); - /** * Allows the user to select any asset known to Penumbra, optionally filtered by * a filter function. @@ -118,7 +115,7 @@ export const AssetSelector = ({ assets, onChange, value, filter }: AssetSelector -
+
Select asset
@@ -127,6 +124,7 @@ export const AssetSelector = ({ assets, onChange, value, filter }: AssetSelector icon={} value={search} onChange={setSearch} + autoFocus placeholder='Search assets...' /> diff --git a/apps/minifront/src/components/shared/selectors/balance-item.tsx b/apps/minifront/src/components/shared/selectors/balance-item.tsx new file mode 100644 index 0000000000..0aad0c58a2 --- /dev/null +++ b/apps/minifront/src/components/shared/selectors/balance-item.tsx @@ -0,0 +1,58 @@ +import { BalanceOrMetadata, isBalance, isMetadata } from './helpers'; +import { getAddressIndex } from '@penumbra-zone/getters/address-view'; +import { getMetadataFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response'; +import { useMemo } from 'react'; +import { DialogClose } from '@repo/ui/components/ui/dialog'; +import { cn } from '@repo/ui/lib/utils'; +import { AssetIcon } from '@repo/ui/components/ui/tx/view/asset-icon'; +import { ValueViewComponent } from '@repo/ui/components/ui/tx/view/value'; + +interface BalanceItemProps { + asset: BalanceOrMetadata; + value?: BalanceOrMetadata; + onSelect: (value: BalanceOrMetadata) => void; +} + +export const BalanceItem = ({ asset, value, onSelect }: BalanceItemProps) => { + const account = isBalance(asset) ? getAddressIndex(asset.accountAddress).account : undefined; + const metadata = isMetadata(asset) ? asset : getMetadataFromBalancesResponseOptional(asset); + + const isSelected = useMemo(() => { + if (!value) return false; + if (isMetadata(value) && isMetadata(asset)) { + return value.equals(asset); + } + if (isBalance(value) && isBalance(asset)) { + return value.equals(asset); + } + return false; + }, [asset, value]); + + return ( +
+ onSelect(asset)}> +
+ {metadata && ( +
+ +

{metadata.symbol || 'Unknown asset'}

+
+ )} + +
+ {isBalance(asset) && ( + + )} +
+ +

{account}

+
+
+
+ ); +}; diff --git a/apps/minifront/src/components/shared/selectors/balance-selector.tsx b/apps/minifront/src/components/shared/selectors/balance-selector.tsx new file mode 100644 index 0000000000..c2248775be --- /dev/null +++ b/apps/minifront/src/components/shared/selectors/balance-selector.tsx @@ -0,0 +1,103 @@ +import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { useId, useState } from 'react'; +import { IconInput } from '@repo/ui/components/ui/icon-input'; +import { Dialog, DialogContent, DialogHeader } from '@repo/ui/components/ui/dialog'; +import { ValueViewComponent } from '@repo/ui/components/ui/tx/view/value'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { Box } from '@repo/ui/components/ui/box'; +import { motion } from 'framer-motion'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { emptyBalanceResponse } from '../../../utils/empty-balance-response'; +import { bySearch } from './search-filters'; +import { BalanceOrMetadata, isMetadata, mergeBalancesAndAssets } from './helpers'; +import { BalanceItem } from './balance-item'; + +interface BalanceSelectorProps { + value: BalancesResponse | undefined; + onChange: (selection: BalancesResponse) => void; + balances?: BalancesResponse[]; + assets?: Metadata[]; +} + +/** + * Renders balances the user holds, and allows the user to select one. This is + * useful for a form where the user wants to send/sell/swap an asset that they + * already hold. + * + * Use `` if you want to render assets that aren't tied to any + * balance. + */ +export default function BalanceSelector({ + value, + balances, + onChange, + assets, +}: BalanceSelectorProps) { + const [search, setSearch] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const layoutId = useId(); + + const allAssets = mergeBalancesAndAssets(balances, assets); + const filteredBalances = search ? allAssets.filter(bySearch(search)) : allAssets; + + const onSelect = (asset: BalanceOrMetadata) => { + if (!isMetadata(asset)) { + onChange(asset); + return; + } + onChange(emptyBalanceResponse(asset)); + }; + + return ( + <> + {!isOpen && ( + setIsOpen(true)} + > + + + )} + + {isOpen && ( + <> + {/* 0-opacity placeholder for layout's sake */} +
+ +
+ + )} + + + +
+ Select asset +
+ + } + value={search} + onChange={setSearch} + autoFocus + placeholder='Search assets...' + /> + +
+

Asset

+

Balance

+

Account

+
+
+ {filteredBalances.map((asset, i) => ( + + ))} +
+
+
+
+
+ + ); +} diff --git a/apps/minifront/src/components/shared/selectors/helpers.ts b/apps/minifront/src/components/shared/selectors/helpers.ts new file mode 100644 index 0000000000..b11fb88f6c --- /dev/null +++ b/apps/minifront/src/components/shared/selectors/helpers.ts @@ -0,0 +1,26 @@ +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { getMetadataFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response'; + +export type BalanceOrMetadata = BalancesResponse | Metadata; + +export const isMetadata = (asset: BalancesResponse | Metadata): asset is Metadata => { + return asset.getType().typeName === Metadata.typeName; +}; + +export const isBalance = (asset: BalancesResponse | Metadata): asset is BalancesResponse => { + return asset.getType().typeName === BalancesResponse.typeName; +}; + +export const mergeBalancesAndAssets = ( + balances: BalancesResponse[] = [], + assets: Metadata[] = [], +): BalanceOrMetadata[] => { + const filteredAssets = assets.filter(asset => { + return !balances.some(balance => { + const balanceMetadata = getMetadataFromBalancesResponseOptional(balance); + return balanceMetadata?.equals(asset); + }); + }); + return [...balances, ...filteredAssets]; +}; diff --git a/apps/minifront/src/components/shared/selectors/search-filters.ts b/apps/minifront/src/components/shared/selectors/search-filters.ts new file mode 100644 index 0000000000..73993de6ce --- /dev/null +++ b/apps/minifront/src/components/shared/selectors/search-filters.ts @@ -0,0 +1,32 @@ +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { isKnown } from '../../swap/helpers'; +import { getDisplayDenomFromView, getSymbolFromValueView } from '@penumbra-zone/getters/value-view'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { type BalanceOrMetadata, isBalance, isMetadata } from './helpers'; + +export const balanceBySearch = + (search: string) => + (balancesResponse: BalancesResponse): boolean => + isKnown(balancesResponse) && + (getDisplayDenomFromView(balancesResponse.balanceView) + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase()) || + getSymbolFromValueView(balancesResponse.balanceView) + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase())); + +export const metadataBySearch = + (search: string) => + (asset: Metadata): boolean => + asset.display.toLocaleLowerCase().includes(search.toLocaleLowerCase()) || + asset.symbol.toLocaleLowerCase().includes(search.toLocaleLowerCase()); + +export const bySearch = + (search: string) => + (asset: BalanceOrMetadata): boolean => { + if (isMetadata(asset)) { + return metadataBySearch(search)(asset); + } + if (isBalance(asset)) return balanceBySearch(search)(asset); + return false; + }; diff --git a/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx b/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx index 18116e4ff1..8ea734d756 100644 --- a/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx +++ b/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx @@ -1,5 +1,6 @@ -import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { BalanceValueView } from '@repo/ui/components/ui/balance-value-view'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; import { Box } from '@repo/ui/components/ui/box'; import { CandlestickPlot } from '@repo/ui/components/ui/candlestick-plot'; import { Input } from '@repo/ui/components/ui/input'; @@ -13,41 +14,25 @@ import { ArrowRight } from 'lucide-react'; import { useEffect, useState } from 'react'; import { getBlockDate } from '../../../fetchers/block-date'; import { AllSlices } from '../../../state'; -import { amountMoreThanBalance } from '../../../state/send'; import { useStoreShallow } from '../../../utils/use-store-shallow'; import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; import { getAddressIndex } from '@penumbra-zone/getters/address-view'; -import { - Metadata, - ValueView, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; -import { AssetSelector } from '../../shared/asset-selector'; -import BalanceSelector from '../../shared/balance-selector'; -import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; +import { AssetSelector } from '../../shared/selectors/asset-selector'; +import BalanceSelector from '../../shared/selectors/balance-selector'; import { useStatus } from '../../../state/status'; import { hasStakingToken } from '../../../fetchers/staking-token'; import { useStakingTokenMetadata } from '../../../state/shared'; import { useBalancesResponses, useSwappableAssets } from '../../../state/swap'; import { FadeIn } from '@repo/ui/components/ui/fade-in'; - -const isValidAmount = (amount: string, assetIn?: BalancesResponse) => - Number(amount) >= 0 && (!assetIn || !amountMoreThanBalance(assetIn, amount)); - -const getKnownZeroValueView = (metadata?: Metadata) => { - return new ValueView({ - valueView: { - case: 'knownAssetId', - value: { amount: new Amount({ lo: 0n }), metadata }, - }, - }); -}; +import { zeroValueView } from '../../../utils/zero-value-view'; +import { isValidAmount } from '../../../state/helpers'; const getAssetOutBalance = ( balancesResponses: BalancesResponse[] = [], assetIn?: BalancesResponse, assetOut?: Metadata, ) => { - if (!assetIn || !assetOut) return getKnownZeroValueView(); + if (!assetIn || !assetOut) return zeroValueView(); const match = balancesResponses.find(balance => { const balanceViewMetadata = getMetadataFromBalancesResponse(balance); @@ -57,7 +42,7 @@ const getAssetOutBalance = ( ); }); const matchedBalance = getBalanceView.optional()(match); - return matchedBalance ?? getKnownZeroValueView(assetOut); + return matchedBalance ?? zeroValueView(assetOut); }; const tokenSwapInputSelector = (state: AllSlices) => ({ @@ -124,7 +109,6 @@ export const TokenSwapInput = () => { step='any' className={'font-bold leading-10 md:h-8 md:text-xl xl:h-10 xl:text-3xl'} onChange={e => { - if (!isValidAmount(e.target.value, assetIn)) return; setAmount(e.target.value); setShowNonNativeFeeWarning(Number(e.target.value) > 0 && !userHasStakingToken); }} @@ -151,12 +135,14 @@ export const TokenSwapInput = () => { {balancesResponses.data && ( )} {assetIn?.balanceView && ( diff --git a/apps/minifront/src/state/helpers.ts b/apps/minifront/src/state/helpers.ts index ed36b76bab..47353785d6 100644 --- a/apps/minifront/src/state/helpers.ts +++ b/apps/minifront/src/state/helpers.ts @@ -1,6 +1,7 @@ import { AuthorizeAndBuildRequest, AuthorizeAndBuildResponse, + BalancesResponse, BroadcastTransactionRequest, BroadcastTransactionResponse, TransactionPlannerRequest, @@ -18,6 +19,8 @@ import { PartialMessage } from '@bufbuild/protobuf'; import { TransactionToast } from '@repo/ui/lib/toast/transaction-toast'; import { TransactionClassification } from '@penumbra-zone/perspective/transaction/classification'; import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; +import { fromValueView } from '@penumbra-zone/types/amount'; +import { BigNumber } from 'bignumber.js'; /** * Handles the common use case of planning, building, and broadcasting a @@ -156,3 +159,22 @@ export const userDeniedTransaction = (e: unknown): boolean => export const unauthenticated = (e: unknown): boolean => e instanceof Error && e.message.startsWith('[unauthenticated]'); + +export const amountMoreThanBalance = ( + asset: BalancesResponse, + /** + * 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 => { + if (!asset.balanceView) { + throw new Error('Missing balanceView'); + } + + const balanceAmt = fromValueView(asset.balanceView); + return Boolean(amountInDisplayDenom) && BigNumber(amountInDisplayDenom).gt(balanceAmt); +}; + +export const isValidAmount = (amount: string, assetIn?: BalancesResponse) => + Number(amount) >= 0 && (!assetIn || !amountMoreThanBalance(assetIn, amount)); diff --git a/apps/minifront/src/state/ibc-out.ts b/apps/minifront/src/state/ibc-out.ts index 13c801d4d6..0d25600abd 100644 --- a/apps/minifront/src/state/ibc-out.ts +++ b/apps/minifront/src/state/ibc-out.ts @@ -14,8 +14,7 @@ import { } from '@penumbra-zone/getters/value-view'; import { getAddressIndex } from '@penumbra-zone/getters/address-view'; import { toBaseUnit } from '@penumbra-zone/types/lo-hi'; -import { planBuildBroadcast } from './helpers'; -import { amountMoreThanBalance } from './send'; +import { amountMoreThanBalance, planBuildBroadcast } from './helpers'; import { getAssetId } from '@penumbra-zone/getters/metadata'; import { assetPatterns } from '@penumbra-zone/types/assets'; import { bech32, bech32m } from 'bech32'; diff --git a/apps/minifront/src/state/send.ts b/apps/minifront/src/state/send.ts index 0868ec81e0..ce936ef0c6 100644 --- a/apps/minifront/src/state/send.ts +++ b/apps/minifront/src/state/send.ts @@ -6,7 +6,7 @@ import { } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { BigNumber } from 'bignumber.js'; import { MemoPlaintext } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb'; -import { plan, planBuildBroadcast } from './helpers'; +import { amountMoreThanBalance, plan, planBuildBroadcast } from './helpers'; import { Fee, @@ -18,7 +18,6 @@ import { } from '@penumbra-zone/getters/value-view'; import { getAddress, getAddressIndex } from '@penumbra-zone/getters/address-view'; import { toBaseUnit } from '@penumbra-zone/types/lo-hi'; -import { fromValueView } from '@penumbra-zone/types/amount'; import { isAddress } from '@penumbra-zone/bech32m/penumbra'; import { ZQueryState, createZQuery } from '@penumbra-zone/zquery'; import { getTransferableBalancesResponses } from '../components/send/helpers'; @@ -171,22 +170,6 @@ const assembleRequest = ({ amount, feeTier, recipient, selection, memo }: SendSl }); }; -export const amountMoreThanBalance = ( - asset: BalancesResponse, - /** - * 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 => { - if (!asset.balanceView) { - throw new Error('Missing balanceView'); - } - - const balanceAmt = fromValueView(asset.balanceView); - return Boolean(amountInDisplayDenom) && BigNumber(amountInDisplayDenom).gt(balanceAmt); -}; - export interface SendValidationFields { recipientErr: boolean; amountErr: boolean; diff --git a/apps/minifront/src/state/swap/index.test.ts b/apps/minifront/src/state/swap/index.test.ts index 9ac1d94ea9..4bf06bc381 100644 --- a/apps/minifront/src/state/swap/index.test.ts +++ b/apps/minifront/src/state/swap/index.test.ts @@ -66,7 +66,7 @@ describe('Swap Slice', () => { test('assetOut can be set', () => { expect(useStore.getState().swap.assetOut).toBeUndefined(); - useStore.getState().swap.setAssetOut(registryAssets[0]!); + useStore.getState().swap.setAssetOut(registryAssets[0]); expect(useStore.getState().swap.assetOut).toBe(registryAssets[0]); }); diff --git a/apps/minifront/src/state/swap/index.ts b/apps/minifront/src/state/swap/index.ts index a09f862f93..10af3701f8 100644 --- a/apps/minifront/src/state/swap/index.ts +++ b/apps/minifront/src/state/swap/index.ts @@ -7,32 +7,42 @@ import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumb import { AllSlices, SliceCreator, useStore } from '..'; import { DurationOption } from './constants'; import { - DutchAuctionSlice, createDutchAuctionSlice, + DutchAuctionSlice, dutchAuctionSubmitButtonDisabledSelector, } from './dutch-auction'; import { - InstantSwapSlice, createInstantSwapSlice, + InstantSwapSlice, instantSwapSubmitButtonDisabledSelector, } from './instant-swap'; -import { PriceHistorySlice, createPriceHistorySlice } from './price-history'; +import { createPriceHistorySlice, PriceHistorySlice } from './price-history'; import { getMetadata } from '@penumbra-zone/getters/value-view'; -import { ZQueryState, createZQuery } from '@penumbra-zone/zquery'; +import { createZQuery, ZQueryState } from '@penumbra-zone/zquery'; import { getSwappableBalancesResponses, isSwappable } from '../../components/swap/helpers'; import { getAllAssets } from '../../fetchers/assets'; +import { emptyBalanceResponse } from '../../utils/empty-balance-response'; +import { isValidAmount } from '../helpers'; + +// When both `balancesResponses` and `swappableAssets` are loaded, set initial assetIn and assetOut +const setInitialAssets = (state: SwapSlice) => { + if (state.swappableAssets.loading || state.balancesResponses.loading) return; + + const firstBalancesResponse = state.balancesResponses.data?.[0]; + const firstMetadata = state.swappableAssets.data?.[0]; + const secondMetadata = state.swappableAssets.data?.[0]; + if (firstBalancesResponse) { + state.setAssetIn(firstBalancesResponse); + state.setAssetOut(firstMetadata); + } else if (firstMetadata) { + state.setAssetIn(emptyBalanceResponse(firstMetadata)); + state.setAssetOut(secondMetadata); + } +}; export const { balancesResponses, useBalancesResponses } = createZQuery({ name: 'balancesResponses', - fetch: async () => { - const balancesResponses = await getSwappableBalancesResponses(); - - if (balancesResponses[0] && !useStore.getState().swap.assetIn) { - useStore.getState().swap.setAssetIn(balancesResponses[0]); - } - - return balancesResponses; - }, + fetch: () => getSwappableBalancesResponses(), getUseStore: () => useStore, get: state => state.swap.balancesResponses, set: setter => { @@ -40,6 +50,7 @@ export const { balancesResponses, useBalancesResponses } = createZQuery({ useStore.setState(state => { state.swap.balancesResponses = newState; }); + setInitialAssets(useStore.getState().swap); }, }); @@ -47,13 +58,7 @@ export const { swappableAssets, useSwappableAssets } = createZQuery({ name: 'swappableAssets', fetch: async () => { const allAssets = await getAllAssets(); - const swappableAssets = allAssets.filter(isSwappable); - - if (swappableAssets[0] && !useStore.getState().swap.assetOut) { - useStore.getState().swap.setAssetOut(swappableAssets[0]); - } - - return swappableAssets; + return allAssets.filter(isSwappable); }, getUseStore: () => useStore, get: state => state.swap.swappableAssets, @@ -62,6 +67,7 @@ export const { swappableAssets, useSwappableAssets } = createZQuery({ useStore.setState(state => { state.swap.swappableAssets = newState; }); + setInitialAssets(useStore.getState().swap); }, }); @@ -76,7 +82,7 @@ export interface SimulateSwapResult { interface Actions { setAssetIn: (asset: BalancesResponse) => void; setAmount: (amount: string) => void; - setAssetOut: (metadata: Metadata) => void; + setAssetOut: (metadata?: Metadata) => void; setDuration: (duration: DurationOption) => void; resetSubslices: VoidFunction; } @@ -177,5 +183,6 @@ export const createSwapSlice = (): SliceCreator => (set, get, store) export const submitButtonDisabledSelector = (state: AllSlices) => !state.swap.amount || + !isValidAmount(state.swap.amount, state.swap.assetIn) || dutchAuctionSubmitButtonDisabledSelector(state) || instantSwapSubmitButtonDisabledSelector(state); diff --git a/apps/minifront/src/state/swap/instant-swap.ts b/apps/minifront/src/state/swap/instant-swap.ts index f37bf96864..394702b869 100644 --- a/apps/minifront/src/state/swap/instant-swap.ts +++ b/apps/minifront/src/state/swap/instant-swap.ts @@ -1,6 +1,6 @@ import { AllSlices, SliceCreator } from '..'; import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; -import { planBuildBroadcast } from '../helpers'; +import { isValidAmount, planBuildBroadcast } from '../helpers'; import { AssetId, Metadata, @@ -168,7 +168,9 @@ const assembleSwapRequest = async ({ amount, assetOut, }: Pick) => { - if (!assetIn) throw new Error('`assetIn` was undefined'); + if (!assetIn) throw new Error('`assetIn` is undefined'); + if (!assetOut) throw new Error('`assetOut` is undefined'); + if (!isValidAmount(amount, assetIn)) throw new Error('Invalid amount'); const addressIndex = getAddressIndex(assetIn.accountAddress); diff --git a/apps/minifront/src/utils/empty-balance-response.ts b/apps/minifront/src/utils/empty-balance-response.ts new file mode 100644 index 0000000000..9e7d892a61 --- /dev/null +++ b/apps/minifront/src/utils/empty-balance-response.ts @@ -0,0 +1,21 @@ +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb'; +import { zeroValueView } from './zero-value-view'; + +/** + * Transforms an asset metadata to a `BalanceResponse` with a zero balance on account 0. + */ +export const emptyBalanceResponse = (metadata: Metadata) => { + return new BalancesResponse({ + balanceView: zeroValueView(metadata), + accountAddress: new AddressView({ + addressView: { + case: 'decoded', + value: { + index: { account: 0 }, + }, + }, + }), + }); +}; diff --git a/packages/ui/components/ui/balance-value-view.tsx b/packages/ui/components/ui/balance-value-view.tsx index f8c8ea8c56..1f685fd186 100644 --- a/packages/ui/components/ui/balance-value-view.tsx +++ b/packages/ui/components/ui/balance-value-view.tsx @@ -11,9 +11,11 @@ import { cn } from '../../lib/utils'; */ export const BalanceValueView = ({ valueView, + error, onClick, }: { valueView: ValueView; + error?: boolean; onClick?: (valueView: ValueView) => void; }) => { const exponent = getDisplayDenomExponentFromValueView.optional()(valueView); @@ -24,6 +26,7 @@ export const BalanceValueView = ({
onClick(valueView) : undefined} diff --git a/packages/ui/components/ui/icon-input.tsx b/packages/ui/components/ui/icon-input.tsx index d70838a17a..ad20503922 100644 --- a/packages/ui/components/ui/icon-input.tsx +++ b/packages/ui/components/ui/icon-input.tsx @@ -1,21 +1,19 @@ import { ReactNode } from 'react'; import { Input } from './input'; -/** - * Use this to render an input with an icon to its left, such as a search field - * with a magnifying glass. - */ -export const IconInput = ({ - value, - onChange, - icon, - placeholder, -}: { +interface IconInputProps { value: string; onChange: (value: string) => void; icon: ReactNode; placeholder?: string; -}) => { + autoFocus?: boolean; +} + +/** + * Use this to render an input with an icon to its left, such as a search field + * with a magnifying glass. + */ +export const IconInput = ({ value, onChange, icon, placeholder, autoFocus }: IconInputProps) => { return (
{icon} @@ -24,6 +22,7 @@ export const IconInput = ({ onChange={e => onChange(e.target.value)} variant='transparent' placeholder={placeholder} + autoFocus={autoFocus} />
); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23bdd02a40..7abe4bbcf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: '@types/react-helmet': specifier: ^6.1.11 version: 6.1.11 + vite: + specifier: ^5.2.11 + version: 5.3.1(@types/node@20.14.4)(terser@5.31.1) apps/node-status: dependencies: