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
30 changes: 12 additions & 18 deletions src/lib/components/orders/MarketOrder.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
});
Expand Down Expand Up @@ -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,
Expand All @@ -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
});
}
Expand All @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions src/lib/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
15 changes: 15 additions & 0 deletions tests/lib/components/orders/MarketOrder.events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 29 additions & 1 deletion tests/lib/utils/format.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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, '-'],
Expand Down
Loading