Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/components/selectors/SearchableSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ export function SearchableSelect<T>({
// 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(
Expand All @@ -100,10 +101,11 @@ export function SearchableSelect<T>({
// 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`;
Expand Down
30 changes: 28 additions & 2 deletions src/components/selectors/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface TokenSelectorProps {
hideZeroBalance?: boolean;
showAllCats?: boolean;
includeXch?: boolean;
allowedAssetIds?: ReadonlySet<string>;
isLoading?: boolean;
}

export function TokenSelector({
Expand All @@ -24,6 +26,8 @@ export function TokenSelector({
hideZeroBalance = false,
showAllCats = false,
includeXch = false,
allowedAssetIds,
isLoading = false,
}: TokenSelectorProps) {
const { addError } = useErrors();

Expand Down Expand Up @@ -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)) {
Expand All @@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -131,18 +143,32 @@ 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 (
<SearchableSelect
value={value === null ? 'xch' : value}
onSelect={handleSelect}
items={filteredTokens}
getItemId={(token) => 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`}
Expand Down
167 changes: 146 additions & 21 deletions src/pages/Swap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -29,6 +46,10 @@ export function Swap() {
const { isTestnet } = useNetwork();

const [ownedTokens, setOwnedTokens] = useState<TokenRecord[]>([]);
const [dexieAssetIds, setDexieAssetIds] = useState<ReadonlySet<string>>(
new Set(),
);
const [isLoadingDexieAssets, setIsLoadingDexieAssets] = useState(true);

const [payAssetId, setPayAssetId] = useState<string | null | undefined>();
const [payAmount, setPayAmount] = useState('');
Expand All @@ -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);
Expand All @@ -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);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed token fetch blocks swaps

Medium Severity

A Dexie token-list failure leaves dexieAssetIds as an empty Set. TokenSelector treats any provided set as an allow-list, so every CAT is hidden. With only XCH left and XCH disabled on the opposite selector, no valid pair can be chosen.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e8dad14. Configure here.


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 (
Expand All @@ -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;
}
Expand All @@ -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,
Expand All @@ -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;
}
Expand All @@ -144,7 +203,7 @@ export function Swap() {
setFee(toDecimal(quote.networkFee, 12));
}
},
[receiveAssetId, hasUserInputFee, addError, isTestnet],
[hasUserInputFee, addError, isTestnet],
);

const offerState = useMemo<OfferState>(() => {
Expand Down Expand Up @@ -198,13 +257,18 @@ export function Swap() {
<TokenSelector
value={payAssetId}
onChange={(value) => {
const nextReceiveAssetId =
value === null ? receiveAssetId : null;
setPayAssetId(value);
updatePayAmount(value, receiveAmount);
if (value !== null) setReceiveAssetId(null);
updatePayAmount(value, nextReceiveAssetId, receiveAmount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pair flip reuses counterpart amount

High Severity

Selecting a CAT on one side forces the other side to XCH but leaves the counterpart amount unchanged. That leftover value is then converted with XCH precision and sent to Dexie, so the new quote (and a still-enabled Swap action) is for the wrong trade.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e8dad14. Configure here.

}}
className='!rounded-r-none'
hideZeroBalance={true}
showAllCats={false}
includeXch={true}
allowedAssetIds={dexieAssetIds}
isLoading={isLoadingDexieAssets}
disabled={
receiveAssetId === undefined ? undefined : [receiveAssetId]
}
Expand All @@ -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}
Expand Down Expand Up @@ -268,13 +336,17 @@ export function Swap() {
<TokenSelector
value={receiveAssetId}
onChange={(value) => {
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]}
/>
<div className='flex flex-grow-0'>
Expand All @@ -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}
Expand Down Expand Up @@ -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<ReadonlySet<string>> {
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(
Expand Down
Loading