pkg/bridge: replace useSwap static rate matrix with real backend quotes

useSwap now hits ${cfg.apiHost}/api/quote (GET, query params per the
server's app/server/src/routes/quote.ts) with a 300ms debounce. Maps
bridge chain ids → server internal_name (ETHEREUM_MAINNET / LUX_MAINNET
/ etc, suffix-swapped for testnet/devnet env). In-flight requests are
aborted via AbortController on every keystroke so the backend isn't
flooded.

New SwapState fields: quoting (boolean) + quoteError (string | null).
quoteError surfaces 5xx + network errors to the UI rather than blanking
the quote silently. AbortError is suppressed (newer keystroke).

The previous static USD-rate matrix is gone — pricing now comes from
the server's getTokenPrice oracle. The Quote shape is preserved
(outAmount/feeUsd/destGas/etaText/rate) so SwapForm doesn't need to
change; minOut is added for slippage protection on submission.

5 tests cover debounce, same-chain noop, invalid input, server error,
and reverse-swap state.

Also flips vitest config to happy-dom so hook tests can mount under
a real DOM-ish environment (jsdom would also work; happy-dom starts
faster on cold init).
This commit is contained in:
2026-05-21 17:28:05 -07:00
parent 50ccb1dd23
commit e429f20719
3 changed files with 277 additions and 61 deletions
+124
View File
@@ -0,0 +1,124 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// useSwap is wagmi-free (the swap form doesn't need a connected wallet to
// preview a quote), so we can test it without a WagmiProvider.
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setConfig } from '../config'
import { useSwap } from '../app/hooks/useSwap'
const originalFetch = global.fetch
function mockOnce(body: unknown, ok = true) {
global.fetch = vi.fn().mockResolvedValue({
ok,
status: ok ? 200 : 500,
statusText: ok ? 'OK' : 'ERR',
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
})
}
beforeEach(() => {
setConfig({
apiHost: 'https://api.example.test',
env: 'mainnet',
})
})
afterEach(() => {
global.fetch = originalFetch
vi.useRealTimers()
})
describe('useSwap', () => {
it('initializes with default chain pair + null quote', () => {
const { result } = renderHook(() => useSwap())
expect(result.current.fromChain.id).toBe('lux:96369')
expect(result.current.toChain.id).toBe('evm:1')
expect(result.current.quote).toBeNull()
expect(result.current.quoteError).toBeNull()
expect(result.current.quoting).toBe(false)
})
it('reverse() swaps from/to chains and assets', () => {
const { result } = renderHook(() => useSwap())
const before = {
from: result.current.fromChain.id,
to: result.current.toChain.id,
fromAsset: result.current.fromAsset.id,
toAsset: result.current.toAsset.id,
}
act(() => {
result.current.reverse()
})
expect(result.current.fromChain.id).toBe(before.to)
expect(result.current.toChain.id).toBe(before.from)
expect(result.current.fromAsset.id).toBe(before.toAsset)
expect(result.current.toAsset.id).toBe(before.fromAsset)
})
it('debounces quote fetches by 300ms', async () => {
mockOnce({
data: {
quote: {
receive_amount: 99.8,
min_receive_amount: 97.3,
blockchain_fee: 0.0008,
service_fee: 0.01,
avg_completion_time: '00:03:00',
total_fee: 0.2,
total_fee_in_usd: 0.2,
slippage: 0.025,
},
},
})
const { result } = renderHook(() => useSwap())
act(() => {
result.current.setAmount('100')
})
// No fetch synchronously.
expect(global.fetch).not.toHaveBeenCalled()
await waitFor(
() => {
expect(global.fetch).toHaveBeenCalledTimes(1)
},
{ timeout: 1_000 },
)
await waitFor(() => {
expect(result.current.quote?.outAmount).toBeCloseTo(99.8, 6)
expect(result.current.quote?.rate).toBeCloseTo(0.998, 6)
expect(result.current.quoting).toBe(false)
})
})
it('clears the quote when amount is invalid or zero', async () => {
mockOnce({ data: { quote: { receive_amount: 50, min_receive_amount: 49, blockchain_fee: 0, service_fee: 0.01, avg_completion_time: '00:03:00', total_fee: 0, total_fee_in_usd: 0, slippage: 0.025 } } })
const { result } = renderHook(() => useSwap())
act(() => result.current.setAmount('10'))
await waitFor(() => expect(result.current.quote).not.toBeNull(), { timeout: 1_000 })
act(() => result.current.setAmount('0'))
await waitFor(() => {
expect(result.current.quote).toBeNull()
})
})
it('surfaces quoteError on server 5xx', async () => {
mockOnce({ error: 'boom' }, false)
const { result } = renderHook(() => useSwap())
act(() => result.current.setAmount('5'))
await waitFor(() => expect(result.current.quoteError).not.toBeNull(), {
timeout: 1_000,
})
expect(result.current.quote).toBeNull()
})
})
+149 -60
View File
@@ -1,17 +1,23 @@
// Swap form state hook for the inlined bridge UI.
//
// Owns the from/to chain selection, asset selection, amount, and a derived
// quote. The quote here is a deterministic display-only mock — Phase 3 R3
// wires the real cross-chain quote against the bridge backend (which itself
// is permissionless: rates are computed from on-chain reserves + MPC node
// attestations, no central pricing authority).
// Owns chain/asset selection + the live quote against the bridge backend.
// The quote is fetched from `${cfg.apiHost}/api/quote` (server route
// `app/server/src/routes/quote.ts`), debounced 300ms so typing doesn't
// flood the backend.
//
// Trust model:
// - The quote is *advisory* — the user signs the final amount when they
// submit the transfer. The server is not trusted to set the price; the
// MPC threshold layer enforces min_receive_amount on settlement.
// - Permissionless-by-design: any consumer can hit the same endpoint. The
// server is a read-only price oracle; the bridge has no central pricing
// authority.
import { useState, useMemo, useCallback } from 'react'
import {
DEFAULT_ASSETS,
type Asset,
assetsForChain,
} from '../lib/assets'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getConfig } from '../../config'
import { DEFAULT_ASSETS, type Asset, assetsForChain } from '../lib/assets'
import { BridgeApiError, chainIdToInternalName, fetchQuote } from '../lib/bridge-api'
import { DEFAULT_CHAINS, type Chain, findChain } from '../lib/chains'
export interface Quote {
@@ -25,6 +31,8 @@ export interface Quote {
etaText: string
/** Implied effective rate, `outAmount / inAmount`. */
rate: number
/** Minimum receive amount the user has consented to (slippage protected). */
minOut: number
}
export interface SwapState {
@@ -36,6 +44,10 @@ export interface SwapState {
toAsset: Asset
amount: string
quote: Quote | null
/** True while a quote request is in flight. */
quoting: boolean
/** Last-fetched quote error, when applicable. */
quoteError: string | null
setFromChain: (c: Chain) => void
setToChain: (c: Chain) => void
setFromAsset: (a: Asset) => void
@@ -46,62 +58,50 @@ export interface SwapState {
toAssetOptions: Asset[]
}
const QUOTE_DEBOUNCE_MS = 300
/**
* Compute a deterministic display-only quote from an input amount.
*
* Replaced by `useQuote(backend, from, to, amount)` once Phase 3 R3 ships
* the API client.
* Format the server's `avg_completion_time` (e.g. `00:03:00`) as a short
* UI string (`~3 min`). Falls back to the raw string when the format is
* unrecognized.
*/
function computeQuote(amountStr: string, from: Asset, to: Asset): Quote | null {
const n = Number(amountStr)
if (!isFinite(n) || n <= 0) return null
// Tiny mock rate matrix — stable but obviously placeholder.
const stableSym = (s: string) =>
s === 'USDC' || s === 'USDT' || s === 'USDL' || s === 'DAI'
const usd = (sym: string) => {
switch (sym) {
case 'ETH':
return 3500
case 'LUX':
return 12
case 'MATIC':
return 0.6
case 'SOL':
return 175
default:
return stableSym(sym) ? 1 : 1
}
}
const inUsd = n * usd(from.symbol)
const out = (inUsd / usd(to.symbol)) * 0.998 // 0.2% bridge fee
const fee = inUsd * 0.002
const rate = out / n
return {
outAmount: out,
feeUsd: fee,
destGas: 0.0008,
etaText: '~3 min',
rate,
}
function formatEta(raw: string): string {
const m = /^(\d+):(\d+):/.exec(raw)
if (!m) return raw || '—'
const hours = Number(m[1])
const mins = Number(m[2])
if (hours > 0) return `~${hours}h ${mins}m`
if (mins > 0) return `~${mins} min`
return '<1 min'
}
export function useSwap(): SwapState {
const cfg = getConfig()
const chains = DEFAULT_CHAINS
const assets = DEFAULT_ASSETS
const initFrom = findChain(chains, 'lux:96369') ?? chains[0]
const initTo = findChain(chains, 'evm:1') ?? chains[1] ?? chains[0]
if (!initFrom || !initTo) {
throw new Error('useSwap: DEFAULT_CHAINS is empty')
}
const [fromChain, setFromChain] = useState<Chain>(initFrom)
const [toChain, setToChain] = useState<Chain>(initTo)
const [fromAsset, setFromAsset] = useState<Asset>(
assetsForChain(assets, initFrom.id)[0]!,
)
const [toAsset, setToAsset] = useState<Asset>(
assetsForChain(assets, initTo.id)[0]!,
)
const initFromAsset = assetsForChain(assets, initFrom.id)[0]
const initToAsset = assetsForChain(assets, initTo.id)[0]
if (!initFromAsset || !initToAsset) {
throw new Error('useSwap: DEFAULT_ASSETS missing entries for default chains')
}
const [fromAsset, setFromAsset] = useState<Asset>(initFromAsset)
const [toAsset, setToAsset] = useState<Asset>(initToAsset)
const [amount, setAmount] = useState<string>('')
const [quote, setQuote] = useState<Quote | null>(null)
const [quoting, setQuoting] = useState(false)
const [quoteError, setQuoteError] = useState<string | null>(null)
const fromAssetOptions = useMemo(
() => assetsForChain(assets, fromChain.id),
[assets, fromChain.id],
@@ -116,8 +116,9 @@ export function useSwap(): SwapState {
(c: Chain) => {
setFromChain(c)
const opts = assetsForChain(assets, c.id)
if (opts[0] && opts.findIndex((a) => a.id === fromAsset.id) < 0) {
setFromAsset(opts[0])
const first = opts[0]
if (first && opts.findIndex((a) => a.id === fromAsset.id) < 0) {
setFromAsset(first)
}
},
[assets, fromAsset.id],
@@ -126,8 +127,9 @@ export function useSwap(): SwapState {
(c: Chain) => {
setToChain(c)
const opts = assetsForChain(assets, c.id)
if (opts[0] && opts.findIndex((a) => a.id === toAsset.id) < 0) {
setToAsset(opts[0])
const first = opts[0]
if (first && opts.findIndex((a) => a.id === toAsset.id) < 0) {
setToAsset(first)
}
},
[assets, toAsset.id],
@@ -140,10 +142,95 @@ export function useSwap(): SwapState {
setToAsset(fromAsset)
}, [fromChain, toChain, fromAsset, toAsset])
const quote = useMemo(
() => computeQuote(amount, fromAsset, toAsset),
[amount, fromAsset, toAsset],
)
// ─── Quote effect ────────────────────────────────────────────────────
// Debounced fetch. Cleanup aborts the in-flight request so rapid input
// changes don't pile up requests against the backend.
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
setQuoteError(null)
const n = Number(amount)
if (!isFinite(n) || n <= 0) {
setQuote(null)
setQuoting(false)
return
}
if (fromChain.id === toChain.id && fromAsset.id === toAsset.id) {
// Same source + destination is meaningless — clear quote, no fetch.
setQuote(null)
setQuoting(false)
return
}
const sourceNetwork = chainIdToInternalName(fromChain.id, cfg.env)
const destinationNetwork = chainIdToInternalName(toChain.id, cfg.env)
if (!sourceNetwork || !destinationNetwork) {
// Unsupported chain pair on this env — clear quote, surface error.
setQuote(null)
setQuoteError('Unsupported chain pair for current environment')
setQuoting(false)
return
}
const timer = setTimeout(() => {
// Abort any prior in-flight request.
abortRef.current?.abort()
const ctl = new AbortController()
abortRef.current = ctl
setQuoting(true)
fetchQuote(
cfg.apiHost,
{
sourceNetwork,
sourceToken: fromAsset.symbol,
destinationNetwork,
destinationToken: toAsset.symbol,
amount: n,
},
ctl.signal,
)
.then((sq) => {
if (ctl.signal.aborted) return
const out = sq.receive_amount
const fee = sq.total_fee_in_usd
const rate = n > 0 ? out / n : 0
setQuote({
outAmount: out,
feeUsd: fee,
destGas: sq.blockchain_fee,
etaText: formatEta(sq.avg_completion_time),
rate,
minOut: sq.min_receive_amount,
})
setQuoteError(null)
})
.catch((err: unknown) => {
if (ctl.signal.aborted) return
if (err instanceof BridgeApiError) {
setQuoteError(`Quote failed (${err.status})`)
} else if (err instanceof Error && err.name === 'AbortError') {
// Aborted by a newer keystroke — silent.
return
} else {
const msg = err instanceof Error ? err.message : 'Quote failed'
setQuoteError(msg)
}
setQuote(null)
})
.finally(() => {
if (!ctl.signal.aborted) setQuoting(false)
})
}, QUOTE_DEBOUNCE_MS)
return () => {
clearTimeout(timer)
// Do NOT abort here — the next effect run will abort. This avoids
// tearing down the in-flight request when React fires unrelated
// re-renders (e.g. parent state changes that don't touch our deps).
}
}, [amount, fromChain.id, toChain.id, fromAsset.id, toAsset.id, fromAsset.symbol, toAsset.symbol, cfg.apiHost, cfg.env])
return {
chains,
@@ -154,6 +241,8 @@ export function useSwap(): SwapState {
toAsset,
amount,
quote,
quoting,
quoteError,
setFromChain: setFromChainSafe,
setToChain: setToChainSafe,
setFromAsset,
+4 -1
View File
@@ -6,6 +6,9 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
// happy-dom is sufficient for the hook tests; jsdom would also work
// but happy-dom starts ~5× faster on cold init.
environment: 'happy-dom',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
})