From e8dad1422b98d1d27fd5f8dafe82866e85475c33 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Wed, 19 Aug 2026 17:36:26 -0400 Subject: [PATCH] fix(swap): restrict quotes to Dexie-supported assets Keep bidirectional quote updates reliable while preventing unsupported CAT selections from reaching the Dexie quote API. --- src/components/selectors/SearchableSelect.tsx | 6 +- src/components/selectors/TokenSelector.tsx | 30 +++- src/pages/Swap.tsx | 167 +++++++++++++++--- 3 files changed, 178 insertions(+), 25 deletions(-) diff --git a/src/components/selectors/SearchableSelect.tsx b/src/components/selectors/SearchableSelect.tsx index 7d6177186..9aaec8dc8 100644 --- a/src/components/selectors/SearchableSelect.tsx +++ b/src/components/selectors/SearchableSelect.tsx @@ -71,9 +71,10 @@ export function SearchableSelect({ // Only clear search if it's not a valid manual input if (!validateManualInput || !validateManualInput(search)) { setSearch(''); + onSearchChange?.(''); } }, - [onSelect, search, validateManualInput], + [onSelect, onSearchChange, search, validateManualInput], ); const handleSearchChange = useCallback( @@ -100,10 +101,11 @@ export function SearchableSelect({ // Clear search when closing, unless it's a valid manual input if (!validateManualInput || !validateManualInput(search)) { setSearch(''); + onSearchChange?.(''); } } }, - [search, validateManualInput], + [onSearchChange, search, validateManualInput], ); const defaultPlaceholder = t`Select item`; diff --git a/src/components/selectors/TokenSelector.tsx b/src/components/selectors/TokenSelector.tsx index 2cb2fe987..d48ae551f 100644 --- a/src/components/selectors/TokenSelector.tsx +++ b/src/components/selectors/TokenSelector.tsx @@ -14,6 +14,8 @@ export interface TokenSelectorProps { hideZeroBalance?: boolean; showAllCats?: boolean; includeXch?: boolean; + allowedAssetIds?: ReadonlySet; + isLoading?: boolean; } export function TokenSelector({ @@ -24,6 +26,8 @@ export function TokenSelector({ hideZeroBalance = false, showAllCats = false, includeXch = false, + allowedAssetIds, + isLoading = false, }: TokenSelectorProps) { const { addError } = useErrors(); @@ -69,6 +73,12 @@ export function TokenSelector({ const filteredTokens = useMemo(() => { return Object.values(tokens).filter((token) => { if (!token.visible) return false; + if ( + token.asset_id !== null && + allowedAssetIds && + !allowedAssetIds.has(token.asset_id.toLowerCase()) + ) + return false; if (hideZeroBalance && token.balance === 0) return false; if (!searchTerm) return true; if (isValidAssetId(searchTerm)) { @@ -80,7 +90,7 @@ export function TokenSelector({ token.ticker?.toLowerCase().includes(searchTerm.toLowerCase()) ); }); - }, [tokens, hideZeroBalance, searchTerm]); + }, [tokens, allowedAssetIds, hideZeroBalance, searchTerm]); const handleSelect = useCallback( (assetId: string | null) => { @@ -92,9 +102,11 @@ export function TokenSelector({ const handleManualInput = useCallback( (assetId: string) => { + if (allowedAssetIds && !allowedAssetIds.has(assetId.toLowerCase())) + return; onChange(assetId); }, - [onChange], + [allowedAssetIds, onChange], ); // Convert disabled array to handle null -> 'xch' conversion @@ -131,6 +143,18 @@ export function TokenSelector({ [], ); + const renderSelectedToken = useCallback( + (token: TokenRecord | undefined) => { + const selectedToken = + token ?? + (value === undefined + ? undefined + : tokens[value === null ? 'xch' : value]); + return selectedToken ? renderToken(selectedToken) : t`Select asset`; + }, + [renderToken, tokens, value], + ); + return ( token.asset_id ?? 'xch'} renderItem={renderToken} + renderSelectedItem={renderSelectedToken} onSearchChange={setSearchTerm} shouldFilter={false} validateManualInput={isValidAssetId} onManualInput={handleManualInput} disabled={disabledIds} + isLoading={isLoading} className={className} placeholder={t`Select asset`} searchPlaceholder={t`Search or enter asset id`} diff --git a/src/pages/Swap.tsx b/src/pages/Swap.tsx index adad31d13..dd2a2f01e 100644 --- a/src/pages/Swap.tsx +++ b/src/pages/Swap.tsx @@ -17,10 +17,27 @@ import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import BigNumber from 'bignumber.js'; import { HandCoins, Handshake } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { z } from 'zod'; import { useNetwork } from '@/hooks/useNetwork'; +const dexieErrorResponseSchema = z.object({ + error_message: z.string().optional(), +}); + +const dexieQuoteResponseSchema = z.object({ + quote: z.object({ + from_amount: z.union([z.number(), z.string()]), + to_amount: z.union([z.number(), z.string()]), + suggested_tx_fee: z.union([z.number(), z.string()]), + }), +}); + +const dexieSwapTokensResponseSchema = z.object({ + tokens: z.array(z.object({ id: z.string() })), +}); + export function Swap() { const walletState = useWalletState(); const navigate = useNavigate(); @@ -29,6 +46,10 @@ export function Swap() { const { isTestnet } = useNetwork(); const [ownedTokens, setOwnedTokens] = useState([]); + const [dexieAssetIds, setDexieAssetIds] = useState>( + new Set(), + ); + const [isLoadingDexieAssets, setIsLoadingDexieAssets] = useState(true); const [payAssetId, setPayAssetId] = useState(); const [payAmount, setPayAmount] = useState(''); @@ -40,6 +61,7 @@ export function Swap() { const [fee, setFee] = useState(''); const [hasUserInputFee, setHasUserInputFee] = useState(false); + const quoteRequestId = useRef(0); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [isProgressDialogOpen, setIsProgressDialogOpen] = useState(false); @@ -60,8 +82,36 @@ export function Swap() { return () => clearInterval(interval); }, [updateCats]); + useEffect(() => { + const controller = new AbortController(); + + setDexieAssetIds(new Set()); + setIsLoadingDexieAssets(true); + + getDexieSwapAssetIds(isTestnet, controller.signal) + .then(setDexieAssetIds) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === 'AbortError') + return; + addError({ + kind: 'dexie', + reason: `Failed to load supported Dexie assets: ${getErrorMessage(error)}`, + }); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoadingDexieAssets(false); + }); + + return () => controller.abort(); + }, [addError, isTestnet]); + const updateReceiveAmount = useCallback( - async (receiveAssetId: string | null, payAmount: string) => { + async ( + payAssetId: string | null | undefined, + receiveAssetId: string | null | undefined, + payAmount: string, + ) => { + const requestId = ++quoteRequestId.current; const mojoAmount = toMojos(payAmount, payAssetId === null ? 12 : 3); if ( @@ -84,10 +134,12 @@ export function Swap() { isTestnet, ); - if (!quote) { + if (requestId !== quoteRequestId.current) return; + + if ('error' in quote) { addError({ kind: 'dexie', - reason: 'Failed to get quote from Dexie. Please try again later.', + reason: `Failed to get quote from Dexie: ${quote.error}`, }); return; } @@ -100,11 +152,16 @@ export function Swap() { setFee(toDecimal(quote.networkFee, 12)); } }, - [payAssetId, hasUserInputFee, addError, isTestnet], + [hasUserInputFee, addError, isTestnet], ); const updatePayAmount = useCallback( - async (payAssetId: string | null, receiveAmount: string) => { + async ( + payAssetId: string | null | undefined, + receiveAssetId: string | null | undefined, + receiveAmount: string, + ) => { + const requestId = ++quoteRequestId.current; const mojoAmount = toMojos( receiveAmount, receiveAssetId === null ? 12 : 3, @@ -130,10 +187,12 @@ export function Swap() { isTestnet, ); - if (!quote) { + if (requestId !== quoteRequestId.current) return; + + if ('error' in quote) { addError({ kind: 'dexie', - reason: 'Failed to get quote from Dexie. Please try again later.', + reason: `Failed to get quote from Dexie: ${quote.error}`, }); return; } @@ -144,7 +203,7 @@ export function Swap() { setFee(toDecimal(quote.networkFee, 12)); } }, - [receiveAssetId, hasUserInputFee, addError, isTestnet], + [hasUserInputFee, addError, isTestnet], ); const offerState = useMemo(() => { @@ -198,13 +257,18 @@ export function Swap() { { + const nextReceiveAssetId = + value === null ? receiveAssetId : null; setPayAssetId(value); - updatePayAmount(value, receiveAmount); + if (value !== null) setReceiveAssetId(null); + updatePayAmount(value, nextReceiveAssetId, receiveAmount); }} className='!rounded-r-none' hideZeroBalance={true} showAllCats={false} includeXch={true} + allowedAssetIds={dexieAssetIds} + isLoading={isLoadingDexieAssets} disabled={ receiveAssetId === undefined ? undefined : [receiveAssetId] } @@ -217,8 +281,12 @@ export function Swap() { value={payAmount} onChange={(e) => { setPayAmount(e.target.value); - if (receiveAssetId) { - updateReceiveAmount(receiveAssetId, e.target.value); + if (receiveAssetId !== undefined) { + updateReceiveAmount( + payAssetId, + receiveAssetId, + e.target.value, + ); } }} precision={payAssetId === null ? 12 : 3} @@ -268,13 +336,17 @@ export function Swap() { { + const nextPayAssetId = value === null ? payAssetId : null; setReceiveAssetId(value); - updateReceiveAmount(value, payAmount); + if (value !== null) setPayAssetId(null); + updateReceiveAmount(nextPayAssetId, value, payAmount); }} className='!rounded-r-none' hideZeroBalance={false} showAllCats={true} includeXch={true} + allowedAssetIds={dexieAssetIds} + isLoading={isLoadingDexieAssets} disabled={payAssetId === undefined ? undefined : [payAssetId]} />
@@ -285,8 +357,12 @@ export function Swap() { value={receiveAmount} onChange={(e) => { setReceiveAmount(e.target.value); - if (payAssetId) { - updatePayAmount(payAssetId, e.target.value); + if (payAssetId !== undefined) { + updatePayAmount( + payAssetId, + receiveAssetId, + e.target.value, + ); } }} precision={receiveAssetId === null ? 12 : 3} @@ -385,17 +461,66 @@ async function getDexieQuote( isTestnet, ), ); - const data = await response.json(); + const data: unknown = await response.json(); + + if (!response.ok) { + return { + error: + getDexieErrorMessage(data) ?? + `Dexie returned HTTP ${response.status}.`, + }; + } + + const parsed = dexieQuoteResponseSchema.safeParse(data); + if (!parsed.success) { + return { error: 'Dexie returned an invalid quote response.' }; + } + + const quotedAmount = + amountKind === 'pay' + ? parsed.data.quote.to_amount + : parsed.data.quote.from_amount; + return { - amount: (amountKind === 'pay' - ? data.quote.to_amount - : data.quote.from_amount) as number, - networkFee: data.quote.suggested_tx_fee as number, + amount: quotedAmount, + networkFee: parsed.data.quote.suggested_tx_fee, }; } catch (error: unknown) { console.error(error); - return null; + return { error: getErrorMessage(error) }; + } +} + +async function getDexieSwapAssetIds( + isTestnet: boolean, + signal: AbortSignal, +): Promise> { + const response = await fetch(dexieApiUrl('v1/swap/tokens', isTestnet), { + signal, + }); + const data: unknown = await response.json(); + + if (!response.ok) { + throw new Error( + getDexieErrorMessage(data) ?? `Dexie returned HTTP ${response.status}.`, + ); } + + const parsed = dexieSwapTokensResponseSchema.safeParse(data); + if (!parsed.success) { + throw new Error('Dexie returned an invalid token list.'); + } + + return new Set(parsed.data.tokens.map((token) => token.id.toLowerCase())); +} + +function getDexieErrorMessage(data: unknown): string | null { + const parsed = dexieErrorResponseSchema.safeParse(data); + return parsed.success ? (parsed.data.error_message ?? null) : null; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } async function executeDexieSwap(