say why a quote was refused, instead of "Quote failed (400)"

This is the UI that is actually deployed — the root Dockerfile builds app/bridge,
which mounts @luxfi/bridge, and lux-bridge, zoo-bridge and pars-bridge all run
that image. Its destination picker offers EVERY network for every source, so
choosing a pair the bridge cannot route is an ordinary thing to do, not an edge
case.

Until now such a pair returned 200 with a fabricated number. It now returns 400
and names the problem — "no bridge route between LBTC and ZETH" — and that
sentence is the whole diagnosis. The hook was throwing it away and rendering the
status code, which reads as the site being broken rather than as the route not
existing.

Falls back to the status when the body carries no message, so nothing ever
renders "undefined" or "[object Object]".

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-07 20:44:18 -07:00
co-authored by Hanzo Dev
parent 66a4d27661
commit d83c58ab7e
2 changed files with 57 additions and 3 deletions
+30 -2
View File
@@ -35,10 +35,10 @@ import { makeTestWrapper } from './test-providers'
const originalFetch = global.fetch
function mockOnce(body: unknown, ok = true) {
function mockOnce(body: unknown, ok = true, status = ok ? 200 : 500) {
global.fetch = vi.fn().mockResolvedValue({
ok,
status: ok ? 200 : 500,
status,
statusText: ok ? 'OK' : 'ERR',
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
@@ -144,4 +144,32 @@ describe('useSwap', () => {
})
expect(result.current.quote).toBeNull()
})
it('shows the server’s reason for a refused pair, not just the status', async () => {
// The picker offers every network for every source, so landing on a pair
// the bridge cannot route is ordinary rather than exceptional. The server
// names the actual problem; showing "Quote failed (400)" instead reads as
// the site being broken.
mockOnce({ error: 'no bridge route between LBTC and ZETH' }, false, 400)
const { result } = renderHook(() => useSwap(), { wrapper: makeTestWrapper() })
act(() => result.current.setAmount('5'))
await waitFor(() => expect(result.current.quoteError).not.toBeNull(), {
timeout: 1_000,
})
expect(result.current.quoteError).toBe('no bridge route between LBTC and ZETH')
expect(result.current.quote).toBeNull()
})
it('falls back to the status when the body carries no message', async () => {
// Never render "undefined" or "[object Object]" at someone.
mockOnce({ nope: true }, false, 400)
const { result } = renderHook(() => useSwap(), { wrapper: makeTestWrapper() })
act(() => result.current.setAmount('5'))
await waitFor(() => expect(result.current.quoteError).not.toBeNull(), {
timeout: 1_000,
})
expect(result.current.quoteError).toBe('Quote failed (400)')
})
})
+27 -1
View File
@@ -93,6 +93,25 @@ function formatEta(raw: string): string {
return '<1 min'
}
/**
* The server's own explanation of a failed quote, when it gave one.
*
* `/api/quote` answers `{ error: "no bridge route between LBTC and ZETH" }`, and
* that sentence is the whole diagnosis — worth far more to whoever is looking at
* the form than the status code. Returns undefined when the body is not shaped
* that way, so the caller keeps its generic fallback rather than rendering
* "undefined" or "[object Object]".
*/
function serverMessage(err: BridgeApiError): string | undefined {
const body = err.body
if (typeof body === 'string') return body.trim() || undefined
if (body && typeof body === 'object' && 'error' in body) {
const e = (body as { error: unknown }).error
if (typeof e === 'string' && e.trim()) return e.trim()
}
return undefined
}
/**
* Pick a sensible default chain ID — Lux first (the canonical source),
* Ethereum second (the canonical destination), else the first chain in the
@@ -315,7 +334,14 @@ export function useSwap(): SwapState {
.catch((err: unknown) => {
if (ctl.signal.aborted) return
if (err instanceof BridgeApiError) {
setQuoteError(`Quote failed (${err.status})`)
// Prefer what the server said. A 4xx from /api/quote names the
// actual problem — "no bridge route between LBTC and ZETH" — and
// the picker offers every network for every source, so an
// unroutable pair is a normal thing for someone to land on rather
// than an edge case. "Quote failed (400)" told them a request had
// failed without telling them the route does not exist, which reads
// as the site being broken.
setQuoteError(serverMessage(err) ?? `Quote failed (${err.status})`)
} else if (err instanceof Error && err.name === 'AbortError') {
// Aborted by a newer keystroke — silent.
return