diff --git a/src/lib/components/orders/MarketOrder.svelte b/src/lib/components/orders/MarketOrder.svelte index cf4d9e43..27e6cfbd 100644 --- a/src/lib/components/orders/MarketOrder.svelte +++ b/src/lib/components/orders/MarketOrder.svelte @@ -3,6 +3,7 @@ import { currentNetwork } from '$lib/stores'; import TradeAmountInput from '$lib/components/TradeAmountInput.svelte'; import { formatUnits } from 'viem'; + import { formatUnitsSafe } from '$lib/utils/format'; import LoadingSpinner from '$lib/components/LoadingSpinner.svelte'; import Icon from '$lib/components/ui/Icon.svelte'; import { isAuthenticated, walletAddress } from '$lib/stores/authStore'; @@ -576,18 +577,20 @@ assetSymbol: assetToken?.symbol, paymentSymbol: paymentToken?.symbol }); + // Snapshot before any await. TradeAmountInput can bind selectedAmount + // to undefined while the swap request is in flight; formatting the + // live binding on trade_failed then throws (ST0-28 / ST0X-DEX-UI-2B). + const submittedAmountFormatted = formatUnitsSafe( + selectedAmount, + inputMode === 'spend' ? paymentToken?.decimals ?? 6 : assetToken?.decimals ?? 18 + ); try { trackTradeEvent('trade_button_clicked', { order_type: 'market', order_side: orderSide.toLowerCase() as 'buy' | 'sell', asset_symbol: assetToken?.symbol, payment_symbol: paymentToken?.symbol, - amount: selectedAmount - ? formatUnits( - selectedAmount, - inputMode === 'spend' ? paymentToken?.decimals ?? 6 : assetToken?.decimals ?? 18 - ) - : '0', + amount: submittedAmountFormatted, slippage_bps: slippageBps, mode: inputMode === 'spend' ? 'spendUpTo' : 'buyUpTo' }); @@ -656,10 +659,7 @@ order_side: orderSide.toLowerCase() as 'buy' | 'sell', asset_symbol: assetToken?.symbol, payment_symbol: paymentToken?.symbol, - amount: formatUnits( - selectedAmount, - inputMode === 'spend' ? paymentToken?.decimals ?? 6 : assetToken?.decimals ?? 18 - ), + amount: submittedAmountFormatted, avg_price: marketPrice, error_class: eventErrorClass, error_message: userFacingError.message, @@ -673,10 +673,7 @@ order_side: orderSide.toLowerCase() as 'buy' | 'sell', asset_symbol: assetToken?.symbol, payment_symbol: paymentToken?.symbol, - amount: formatUnits( - selectedAmount, - inputMode === 'spend' ? paymentToken?.decimals ?? 6 : assetToken?.decimals ?? 18 - ), + amount: submittedAmountFormatted, avg_price: marketPrice }); } @@ -696,10 +693,7 @@ order_side: orderSide.toLowerCase() as 'buy' | 'sell', asset_symbol: assetToken?.symbol, payment_symbol: paymentToken?.symbol, - amount: formatUnits( - selectedAmount, - inputMode === 'spend' ? paymentToken?.decimals ?? 6 : assetToken?.decimals ?? 18 - ), + amount: submittedAmountFormatted, avg_price: marketPrice, error_class: userFacingError.errorClass, error_message: userFacingError.message, diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index f945c8f2..aa83060f 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -2,8 +2,28 @@ * Format utilities to replace duplicate code across the app */ +import { formatUnits } from 'viem'; + const ETH_ADDRESS_RE = /^0x[a-f0-9]{40}$/i; +/** + * Format a token amount for display or analytics without throwing. + * viem's formatUnits calls value.toString(); an undefined amount after a + * failed trade (cleared bound input) otherwise crashes the failure path. + */ +export function formatUnitsSafe( + amount: bigint | null | undefined, + decimals: number | null | undefined +): string { + if (amount === undefined || amount === null) return '0'; + if (typeof decimals !== 'number' || !Number.isFinite(decimals) || decimals < 0) return '0'; + try { + return formatUnits(amount, decimals); + } catch { + return '0'; + } +} + /** * Validate an Ethereum address (0x + 40 hex chars, case-insensitive) */ diff --git a/tests/lib/components/orders/MarketOrder.events.test.ts b/tests/lib/components/orders/MarketOrder.events.test.ts index d4c8c891..fd67228b 100644 --- a/tests/lib/components/orders/MarketOrder.events.test.ts +++ b/tests/lib/components/orders/MarketOrder.events.test.ts @@ -136,6 +136,21 @@ describe('MarketOrder.svelte event instrumentation (Plan 02-03 Task 1a)', () => ); }); + it('Test 11: snapshots the submitted amount before executeMarketOrder so a cleared input cannot crash trade_failed', () => { + const handlerStart = componentSource.indexOf('const handleMarketOrder'); + const handlerEnd = componentSource.indexOf('};', handlerStart) + 2; + const handlerBlock = componentSource.slice(handlerStart, handlerEnd); + const executeIdx = handlerBlock.indexOf('await executeMarketOrder('); + expect(executeIdx).toBeGreaterThan(-1); + + const beforeExecute = handlerBlock.slice(0, executeIdx); + const afterExecute = handlerBlock.slice(executeIdx); + + expect(beforeExecute).toMatch(/formatUnitsSafe\s*\(/); + expect(afterExecute).not.toMatch(/formatUnits\s*\(\s*selectedAmount/); + expect(handlerBlock).toMatch(/trackTradeEvent\(\s*['"]trade_failed['"]/); + }); + it('Test 10: remaps unexpected post-preparation failures at the wallet boundary', () => { expect(componentSource).toMatch(/inferWalletFailureStage/); expect(componentSource).toMatch( diff --git a/tests/lib/utils/format.test.ts b/tests/lib/utils/format.test.ts index 65aefdb2..d1d06238 100644 --- a/tests/lib/utils/format.test.ts +++ b/tests/lib/utils/format.test.ts @@ -1,7 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect } from 'vitest'; -import { truncateAddress, formatUsd, formatPoints, formatApy } from '$lib/utils/format'; +import { formatUnits } from 'viem'; +import { + truncateAddress, + formatUsd, + formatPoints, + formatApy, + formatUnitsSafe +} from '$lib/utils/format'; describe('format utilities', () => { describe('truncateAddress', () => { @@ -57,6 +64,27 @@ describe('format utilities', () => { }); }); + describe('formatUnitsSafe', () => { + it('does not throw when amount is undefined (ST0-28 / ST0X-DEX-UI-2B)', () => { + expect(() => formatUnits(undefined as unknown as bigint, 18)).toThrow( + /Cannot read properties of undefined/ + ); + expect(() => formatUnitsSafe(undefined, 18)).not.toThrow(); + expect(formatUnitsSafe(undefined, 18)).toBe('0'); + }); + + it('does not throw when amount is null or decimals are missing', () => { + expect(formatUnitsSafe(null, 6)).toBe('0'); + expect(formatUnitsSafe(1_000_000n, undefined)).toBe('0'); + expect(formatUnitsSafe(1_000_000n, null)).toBe('0'); + }); + + it('formats a valid amount the same as viem formatUnits', () => { + expect(formatUnitsSafe(1_000_000n, 6)).toBe(formatUnits(1_000_000n, 6)); + expect(formatUnitsSafe(0n, 18)).toBe('0'); + }); + }); + describe('formatApy', () => { it.each([ [null, '-'],