From b26c27b729d3c48a13a50ab47240d60eccd6f1e0 Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 04:38:51 +0100 Subject: [PATCH 01/11] Config: five feed-backed tokens, with a test that keeps the three sections in sync tokens, tokenList and chainlinkFeeds describe the same tokens three ways and nothing cross-checked them, so USDe was in tokens but not tokenList and cbBTC/USDT were in chainlinkFeeds but not tokens. Adds USDT and cbBTC as tokens, USDe/USDT/cbBTC to the picker list, and a verified USDe/USD feed, so every offered token has a live mid. symbol/name/decimals and the feed identity were read from real Base. --- config/addresses.8453.json | 31 +++++++++++--- packages/arbitration-sdk/package.json | 2 +- packages/arbitration-sdk/src/config.test.ts | 46 +++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 packages/arbitration-sdk/src/config.test.ts diff --git a/config/addresses.8453.json b/config/addresses.8453.json index b5b7c70..34b8f44 100644 --- a/config/addresses.8453.json +++ b/config/addresses.8453.json @@ -16,9 +16,11 @@ "tokens": { "WETH": "0x4200000000000000000000000000000000000006", "USDC": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "USDe": "0x5d3a1ff2b6bab83b63cd9ad0787074081a52ef34" + "USDe": "0x5d3a1ff2b6bab83b63cd9ad0787074081a52ef34", + "USDT": "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2", + "cbBTC": "0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf" }, - "_tokens": "Native USDC (6dp) and WETH (18dp) on Base. Sorted WETH < USDC, which is the tokenA/tokenB order MakerTraits requires. Addresses only — contracts/test/ForkVenue.t.sol parses .tokens. as an address, so keep this a flat map.", + "_tokens": "The five tokens the composer offers, on Base. Addresses only, lowercase — contracts/test/ForkVenue.t.sol parses .tokens. as an address, so keep this a flat map. WETH < USDC by address, which is the tokenA/tokenB order MakerTraits requires; ordering for any pair is ascending address. Every symbol here MUST also appear in tokenList and chainlinkFeeds — src/config.test.ts enforces it.", "tokenList": [ { @@ -32,15 +34,34 @@ "name": "USD Coin", "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "decimals": 6 + }, + { + "symbol": "USDe", + "name": "USDe", + "address": "0x5d3a1ff2b6bab83b63cd9ad0787074081a52ef34", + "decimals": 18 + }, + { + "symbol": "USDT", + "name": "Tether USD", + "address": "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2", + "decimals": 6 + }, + { + "symbol": "cbBTC", + "name": "Coinbase Wrapped BTC", + "address": "0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf", + "decimals": 8 } ], - "_tokenList": "Same tokens as above with display metadata for the app's token picker (packages/app/src/lib/tokens.ts). Keep the two in sync.", + "_tokenList": "The same tokens as above with display metadata, and THE list the app's picker offers (packages/app/src/lib/tokens.ts) and the SDK reads decimals from (src/context.ts). symbol/name/decimals were read from real Base on 2026-07-26. A token is offered only if it also has a chainlinkFeeds entry, so every selectable pair has a live mid; src/config.test.ts enforces that the three sections hold identical symbol sets.", "chainlinkFeeds": { "WETH": { "feed": "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70", "description": "ETH / USD", "decimals": 8 }, "cbBTC": { "feed": "0x07DA0E54543a844a80ABE69c8A12F22B3aA59f9D", "description": "cbBTC / USD", "decimals": 8 }, "USDC": { "feed": "0x458138Fc0D67027E9A6778ef40a6ffC318c69061", "description": "USDC / USD", "decimals": 8 }, - "USDT": { "feed": "0xf19d560eB8d2ADf07BD6D13ed03e1D11215721F9", "description": "USDT / USD", "decimals": 8 } + "USDT": { "feed": "0xf19d560eB8d2ADf07BD6D13ed03e1D11215721F9", "description": "USDT / USD", "decimals": 8 }, + "USDe": { "feed": "0x790181e93e9F4Eedb5b864860C12e4d2CffFe73B", "description": "USDe / USD", "decimals": 8 } }, - "_chainlinkFeeds": "Base mainnet Chainlink AggregatorV3 USD feeds. Source: data.chain.link, verified live 2026-07-26 (description()/decimals()/latestRoundData() all read against Base). description+decimals are EXPECTED values the fetcher asserts against on-chain reads (src/pricefeed.ts). Reads hit REAL Base, never the pinned fork. Mid for a pair = usd(token0)/usd(token1)." + "_chainlinkFeeds": "Base mainnet Chainlink AggregatorV3 USD feeds. Source: data.chain.link, verified live 2026-07-26 (description()/decimals()/latestRoundData() all read against Base; USDe added and verified the same day — 'USDe / USD', 8dp, answer 99986842). All five belong to the 8-decimal feed family; the directory also lists 18-decimal variants per pair, which are NOT what we pin. description+decimals are EXPECTED values the fetcher asserts against on-chain reads (src/pricefeed.ts). Reads hit REAL Base, never the pinned fork. Mid for a pair = usd(token0)/usd(token1)." } diff --git a/packages/arbitration-sdk/package.json b/packages/arbitration-sdk/package.json index 15ee7c6..a0d632b 100644 --- a/packages/arbitration-sdk/package.json +++ b/packages/arbitration-sdk/package.json @@ -16,7 +16,7 @@ "subgraph": "tsx src/subgraph-cli.ts", "fund": "tsx src/fund-cli.ts", "typecheck": "tsc --noEmit", - "test": "tsx --test src/proof.test.ts src/recommendation.test.ts src/fallback.test.ts src/swapvm.test.ts src/fixtures.test.ts src/grammar.test.ts src/validate.test.ts src/subgraph.test.ts src/context.test.ts src/pricefeed.test.ts src/compose.test.ts src/appetite.test.ts src/pairing.test.ts src/tiers.test.ts src/serve.test.ts src/compile.test.ts", + "test": "tsx --test src/config.test.ts src/proof.test.ts src/recommendation.test.ts src/fallback.test.ts src/swapvm.test.ts src/fixtures.test.ts src/grammar.test.ts src/validate.test.ts src/subgraph.test.ts src/context.test.ts src/pricefeed.test.ts src/compose.test.ts src/appetite.test.ts src/pairing.test.ts src/tiers.test.ts src/serve.test.ts src/compile.test.ts", "fixtures": "tsx src/fixtures-cli.ts" }, "dependencies": { diff --git a/packages/arbitration-sdk/src/config.test.ts b/packages/arbitration-sdk/src/config.test.ts new file mode 100644 index 0000000..7d82143 --- /dev/null +++ b/packages/arbitration-sdk/src/config.test.ts @@ -0,0 +1,46 @@ +// The address book is the ONE place a chain-specific value lives (F1 §1), and +// it has three sections that describe the same tokens three ways: `tokens` +// (flat, parsed by Forge), `tokenList` (display metadata for the app's picker) +// and `chainlinkFeeds` (the USD feed each mid is derived from). Nothing at +// runtime cross-checks them, so a token added to one section and forgotten in +// the others presents as a picker row that cannot compile, or a pair with no +// mid. These tests are that cross-check. +import test from "node:test"; +import assert from "node:assert/strict"; +import addresses from "../../../config/addresses.8453.json"; + +const flatTokens = addresses.tokens as Record; + +test("tokens, tokenList and chainlinkFeeds describe exactly the same symbols", () => { + const tokens = Object.keys(flatTokens).sort(); + const list = addresses.tokenList.map((t) => t.symbol).sort(); + const feeds = Object.keys(addresses.chainlinkFeeds).sort(); + + assert.deepEqual(list, tokens, "tokenList and tokens disagree"); + assert.deepEqual(list, feeds, "tokenList and chainlinkFeeds disagree"); +}); + +test("every tokenList address matches the flat tokens map", () => { + for (const t of addresses.tokenList) { + assert.equal( + t.address.toLowerCase(), + flatTokens[t.symbol].toLowerCase(), + `${t.symbol}: address disagrees between tokens and tokenList`, + ); + } +}); + +test("every tokenList entry carries display metadata the picker can render", () => { + for (const t of addresses.tokenList) { + assert.ok(t.name.length > 0, `${t.symbol} has no name`); + assert.ok( + Number.isInteger(t.decimals) && t.decimals >= 0 && t.decimals <= 18, + `${t.symbol} has implausible decimals ${t.decimals}`, + ); + } +}); + +test("the five tokens the picker offers are all present", () => { + const list = addresses.tokenList.map((t) => t.symbol).sort(); + assert.deepEqual(list, ["USDC", "USDT", "USDe", "WETH", "cbBTC"]); +}); From 635ea5dd3fe4f3b20bed88862a7fc7b7d1f442b1 Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 04:52:55 +0100 Subject: [PATCH 02/11] SDK: the token map comes from the address book, not a second literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit context.ts carried its own hardcoded WETH+USDC map that compile.ts derives decimals from, so a budget token outside it threw in compileRecommendation and degraded to an empty shipInputs — even though the picker offered it. F1 section 1 says nothing reads a chain-specific value from anywhere but config/addresses.8453.json. Also fixes tokenBySymbol, which keyed on symbol.toUpperCase() and so could never find USDe or cbBTC. The compose CLI takes symbols straight from --budget. --- packages/arbitration-sdk/src/context.ts | 35 ++++++++++++---------- packages/arbitration-sdk/src/serve.test.ts | 5 ++-- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/arbitration-sdk/src/context.ts b/packages/arbitration-sdk/src/context.ts index 4a1f46d..b5bbacd 100644 --- a/packages/arbitration-sdk/src/context.ts +++ b/packages/arbitration-sdk/src/context.ts @@ -20,25 +20,30 @@ import { subgraphUrl, type UserBook, } from "./subgraph.ts"; +import addresses from "../../../config/addresses.8453.json"; export type TokenInfo = { symbol: string; address: string; decimals: number }; -// Base mainnet token addresses (from config/addresses.8453.json). -export const TOKENS: Record = { - USDC: { - symbol: "USDC", - address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - decimals: 6, - }, - WETH: { - symbol: "WETH", - address: "0x4200000000000000000000000000000000000006", - decimals: 18, - }, -}; - +// The token map, from the ONE address book (F1 §1). This used to be a hardcoded +// WETH+USDC literal, which compile.ts derives decimals from — so a budget token +// outside it threw at compile time even though the picker offered it. +// Keyed by exact symbol so TOKENS.USDC keeps resolving; look up by user input +// through tokenBySymbol, never by indexing directly. +export const TOKENS: Record = Object.fromEntries( + addresses.tokenList.map((t) => [ + t.symbol, + { symbol: t.symbol, address: t.address, decimals: t.decimals }, + ]), +); + +// Case-insensitive: symbols are mixed-case (USDe, cbBTC), so keying on +// symbol.toUpperCase() silently finds nothing for exactly the tokens whose +// symbols are not all-caps. Mirrors pricefeed.ts's feedFor. export function tokenBySymbol(symbol: string): TokenInfo | undefined { - return TOKENS[symbol.toUpperCase()]; + const key = Object.keys(TOKENS).find( + (k) => k.toLowerCase() === symbol.toLowerCase(), + ); + return key ? TOKENS[key] : undefined; } // Job 2 — the market. STILL A STUB (F3 Open Q2). diff --git a/packages/arbitration-sdk/src/serve.test.ts b/packages/arbitration-sdk/src/serve.test.ts index 36ef408..e6a2502 100644 --- a/packages/arbitration-sdk/src/serve.test.ts +++ b/packages/arbitration-sdk/src/serve.test.ts @@ -92,8 +92,9 @@ test("composeForApp returns compiled shipInputs, one per strategy", async () => test("composeForApp degrades to shipInputs: [] instead of throwing when a budget token is unknown to the SDK", async () => { delete process.env.ZG_PRIVATE_KEY; - // Not in context.ts's hardcoded TOKENS map (only WETH/USDC) — compileRecommendation's - // decimalsOf() throws on this, and fallbackResult must not let that escape. + // Not in the config token list — compileRecommendation's decimalsOf() throws + // on any address the address book does not carry, and fallbackResult must not + // let that escape. const UNKNOWN = { address: "0x1111111111111111111111111111111111111111", symbol: "UNKNOWN", From 8ad711decfdd1cd4cbc5dc892ccfb4e92b85b682 Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 04:53:45 +0100 Subject: [PATCH 03/11] SDK: the market pair comes from the budget, not a WETH/USDC default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit liveContext was called with no pairTokens, so every request was described to the model as WETH/USDC. pairingPlan splits that string and looks both symbols up in the budget, so any other pair returned null and the pairing block — the value-matching arithmetic that keeps the model from shipping off-mid — disappeared from the prompt with nothing to show it had. pairTokensFor derives the pair in ascending-address order (I10's order), and gives up rather than invent one for a budget that is not two tokens. --- packages/arbitration-sdk/src/context.ts | 18 ++++++++++++++++ packages/arbitration-sdk/src/serve.ts | 28 +++++++++++++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/arbitration-sdk/src/context.ts b/packages/arbitration-sdk/src/context.ts index b5bbacd..75ed498 100644 --- a/packages/arbitration-sdk/src/context.ts +++ b/packages/arbitration-sdk/src/context.ts @@ -46,6 +46,24 @@ export function tokenBySymbol(symbol: string): TokenInfo | undefined { return key ? TOKENS[key] : undefined; } +/** + * The pair a request is about, derived from its budget. + * + * Ascending address, which is I10's canonical order and the tokenA/tokenB order + * MakerTraits requires — and already what "WETH/USDC" is. `undefined` for any + * budget that is not exactly two tokens: there is no pair to name, and naming + * one anyway is how a USDC-only request ended up described as WETH/USDC. + */ +export function pairTokensFor( + budget: Array<{ symbol: string; address: string }>, +): [string, string] | undefined { + if (budget.length !== 2) return undefined; + const [a, b] = [...budget].sort((x, y) => + x.address.toLowerCase() < y.address.toLowerCase() ? -1 : 1, + ); + return [a.symbol, b.symbol]; +} + // Job 2 — the market. STILL A STUB (F3 Open Q2). export type PairContext = { pair: string; diff --git a/packages/arbitration-sdk/src/serve.ts b/packages/arbitration-sdk/src/serve.ts index ac35bbe..9be3a73 100644 --- a/packages/arbitration-sdk/src/serve.ts +++ b/packages/arbitration-sdk/src/serve.ts @@ -14,7 +14,12 @@ import { loadConfig, type Config } from "./config.ts"; import { initBroker, type ChatMessage, type ZGBroker } from "./inference.ts"; import { chainStateFor, compose, PROMPT_VERSION } from "./compose.ts"; import { compileRecommendation, deriveSaltSeed } from "./compile.ts"; -import { liveContext, stubContext, type MarketContext } from "./context.ts"; +import { + liveContext, + pairTokensFor, + stubContext, + type MarketContext, +} from "./context.ts"; import { FALLBACK_SOURCE, templateFallback, @@ -111,8 +116,17 @@ function getBroker(cfg: Config): Promise { // computed from it would already be in the past. Re-key it to the request's // wall clock — the SAME `now` the validator uses, so the two can never drift // across the second boundary between two Date.now() calls. -function nowContext(now: number): MarketContext { - return { ...stubContext(), observedAt: now }; +function nowContext( + now: number, + pairTokens?: [string, string], +): MarketContext { + const base = stubContext(); + if (!pairTokens) return { ...base, observedAt: now }; + return { + ...base, + observedAt: now, + pair: { ...base.pair, pair: `${pairTokens[0]}/${pairTokens[1]}` }, + }; } // The validator clock. observedAt/observedBlock are SNAPSHOT facts the model @@ -158,7 +172,7 @@ function fallbackResult( now: number, maker: string, ): ServerComposeResult { - const ctx = nowContext(now); + const ctx = nowContext(now, pairTokensFor(req.budget)); const rec = templateFallback(req, ctx); const { ok, violations } = verdict(rec, req, ctx, now); // compileRecommendation throws on a token outside the SDK's hardcoded @@ -220,9 +234,11 @@ export async function composeForApp( // and `contextSource` carries the degradation into the response. let ctx: MarketContext; try { - ctx = await liveContext(input.user); + ctx = await liveContext(input.user, { + pairTokens: pairTokensFor(req.budget), + }); } catch { - ctx = nowContext(now); + ctx = nowContext(now, pairTokensFor(req.budget)); } try { From 118e3572cda18056faeefa4d155ac154ff8f094e Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 04:54:46 +0100 Subject: [PATCH 04/11] SDK: a stub mid may only travel with the stub pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STUB_PAIR.midPrice is 3450, a WETH/USDC number, and the pair label was always WETH/USDC too — so they agreed by accident. Now that the label comes from the budget, carrying that mid onto USDe/USDC would have pairingPlan value-match a stable pair at 3450:1. stubPairFor keeps the mid only for the pair it belongs to; for any other pair the mid is 0 and labelled deferred, which pairing.ts already rejects. The prompt loses the block instead of gaining wrong arithmetic, and contextPromptBlock prints "unavailable" rather than a mid of 0. --- packages/arbitration-sdk/src/context.ts | 38 ++++++++++++++++++++++--- packages/arbitration-sdk/src/serve.ts | 7 ++--- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/arbitration-sdk/src/context.ts b/packages/arbitration-sdk/src/context.ts index 75ed498..579e9e1 100644 --- a/packages/arbitration-sdk/src/context.ts +++ b/packages/arbitration-sdk/src/context.ts @@ -189,6 +189,29 @@ export function stubContext(): MarketContext { }; } +/** + * The stub pair, re-keyed to the tokens a request actually names. + * + * STUB_PAIR's midPrice is a WETH/USDC number. It may only travel with the + * WETH/USDC label — carried onto another pair it is not a stale price, it is + * the wrong price, and pairingPlan would divide by it. So for any other pair + * the mid is 0 and labelled "deferred": pairing.ts:110 rejects a zero mid, so + * the prompt loses the pairing block rather than gaining wrong arithmetic. + */ +export function stubPairFor(pairTokens: [string, string]): { + pair: PairContext; + pairFieldSource: PairLiveness; +} { + const label = `${pairTokens[0]}/${pairTokens[1]}`; + if (label === STUB_PAIR.pair) { + return { pair: STUB_PAIR, pairFieldSource: ALL_STUB_LIVENESS }; + } + return { + pair: { ...STUB_PAIR, pair: label, midPrice: 0 }, + pairFieldSource: { ...ALL_STUB_LIVENESS, midPrice: "deferred" }, + }; +} + // Build a MarketContext with a REAL book (job 1) read from the subgraph, keyed // to the subgraph's indexed head block. The market (job 2) stays a stub until // F3 Open Q2 settles the price source. Network call — used by the CLI, not the @@ -211,8 +234,10 @@ export async function liveContext( // The pair (job 2). An injected pair wins (tests / offline). Otherwise fetch // the real mid from Chainlink; a price-read failure degrades to a labelled // stub pair — it must NOT sink the (real) book we just read. - let pair = opts.pair ?? STUB_PAIR; - let pairFieldSource = opts.pairFieldSource ?? ALL_STUB_LIVENESS; + const requested = opts.pairTokens ?? ["WETH", "USDC"]; + const initial = stubPairFor([requested[0], requested[1]]); + let pair = opts.pair ?? initial.pair; + let pairFieldSource = opts.pairFieldSource ?? initial.pairFieldSource; if (!opts.pair) { try { const [t0, t1] = opts.pairTokens ?? ["WETH", "USDC"]; @@ -220,7 +245,12 @@ export async function liveContext( pair = fetched.pair; pairFieldSource = fetched.pairFieldSource; } catch { - // keep the stub pair + all-stub labels: honest degrade, not a crash. + // Honest degrade, not a crash: keep the real book we just read, and + // present the pair with no mid rather than the stub's WETH/USDC one. + const [t0, t1] = opts.pairTokens ?? ["WETH", "USDC"]; + const stub = stubPairFor([t0, t1]); + pair = stub.pair; + pairFieldSource = stub.pairFieldSource; } } @@ -262,7 +292,7 @@ export function contextPromptBlock(ctx: MarketContext): string { .join("\n"); return [ `MARKET CONTEXT (per-field liveness tagged; observed at block ${ctx.observedBlock}):`, - ` pair ${p.pair} | mid ${p.midPrice} [${tag("midPrice")}] | feeTier ${p.feeTierBps}bps [${tag("feeTierBps")}]`, + ` pair ${p.pair} | mid ${p.midPrice > 0 ? p.midPrice : "unavailable"} [${tag("midPrice")}] | feeTier ${p.feeTierBps}bps [${tag("feeTierBps")}]`, ` poolDepth $${p.poolDepthUsd.toLocaleString("en-US")} [${tag("poolDepthUsd")}] | realizedVol(7d) ${p.realizedVol7dPct}% [${tag("realizedVol7dPct")}] | volume(24h) $${p.recentVolume24hUsd.toLocaleString("en-US")} [${tag("recentVolume24hUsd")}]`, bookHeader, ` live strategies: ${ctx.userBook.liveStrategyCount} (of ${ctx.userBook.strategyCount} total)`, diff --git a/packages/arbitration-sdk/src/serve.ts b/packages/arbitration-sdk/src/serve.ts index 9be3a73..94855ef 100644 --- a/packages/arbitration-sdk/src/serve.ts +++ b/packages/arbitration-sdk/src/serve.ts @@ -18,6 +18,7 @@ import { liveContext, pairTokensFor, stubContext, + stubPairFor, type MarketContext, } from "./context.ts"; import { @@ -122,11 +123,7 @@ function nowContext( ): MarketContext { const base = stubContext(); if (!pairTokens) return { ...base, observedAt: now }; - return { - ...base, - observedAt: now, - pair: { ...base.pair, pair: `${pairTokens[0]}/${pairTokens[1]}` }, - }; + return { ...base, observedAt: now, ...stubPairFor(pairTokens) }; } // The validator clock. observedAt/observedBlock are SNAPSHOT facts the model From 7fb0f9bd683438d4f305d51e9cfa0fe3b9b271af Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 04:56:56 +0100 Subject: [PATCH 05/11] SDK: correct a comment Task 3 made false The map is the address book now, not a hardcoded literal. --- packages/arbitration-sdk/src/serve.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/arbitration-sdk/src/serve.ts b/packages/arbitration-sdk/src/serve.ts index 94855ef..7cd29e4 100644 --- a/packages/arbitration-sdk/src/serve.ts +++ b/packages/arbitration-sdk/src/serve.ts @@ -172,10 +172,10 @@ function fallbackResult( const ctx = nowContext(now, pairTokensFor(req.budget)); const rec = templateFallback(req, ctx); const { ok, violations } = verdict(rec, req, ctx, now); - // compileRecommendation throws on a token outside the SDK's hardcoded - // TOKENS map (compile.ts's decimalsOf) — a budget token we can't compile - // still degrades to a labelled fallback, never a throw; shipInputs is - // just empty rather than the request failing outright. + // compileRecommendation throws on a token the address book does not carry + // (compile.ts's decimalsOf) — a budget token we can't compile still degrades + // to a labelled fallback, never a throw; shipInputs is just empty rather + // than the request failing outright. let shipInputs: WireShipInput[]; try { shipInputs = shipInputsFor(rec, maker, null); From 9a520cba9c60adebe74bc4f9c348c432cf9e2390 Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 04:59:22 +0100 Subject: [PATCH 06/11] App: a position's pair label comes from its own legs recordShipped built one pair string from the entire selectable token list and stamped it on every strategy. That was indistinguishable from correct while the list held two tokens; with five it yields "WETH / USDe / cbBTC / USDC / USDT". metaFromUiStrategy derives the label per strategy from that strategy's legs, and is unit-tested. --- packages/app/src/lib/book.tsx | 26 ++---------------------- packages/app/src/lib/join-book.ts | 33 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/app/src/lib/book.tsx b/packages/app/src/lib/book.tsx index c7b1cda..04a2511 100644 --- a/packages/app/src/lib/book.tsx +++ b/packages/app/src/lib/book.tsx @@ -16,13 +16,12 @@ import { demoBook } from "./demo-book"; import { joinBook, metaFromPosition, - pairFromTokens, + metaFromUiStrategy, positionFromMeta, type CachedStrategyMeta, type StrategyCache, } from "./join-book"; import { revivePosition, type PositionDto } from "./position-dto"; -import { TOKENS } from "./tokens"; import type { UiRecommendation } from "./compose/from-server"; /** @@ -273,29 +272,8 @@ export function BookProvider({ children }: { children: ReactNode }) { const refetch = useCallback(() => setRefetchTick((t) => t + 1), []); const recordShipped = useCallback((rec: UiRecommendation, hashes: Hex[]) => { - const pair = pairFromTokens(TOKENS); const entries: Array<[Hex, CachedStrategyMeta]> = rec.strategies.map( - (s, i) => [ - hashes[i], - { - pair, - templateLabel: s.templateShort, - description: s.description, - bandKind: s.bandKind, - band: s.band, - bandNote: s.bandNote, - legs: s.legs.map(({ token, virtual }) => ({ - token: token.address, - symbol: token.symbol, - decimals: token.decimals, - virtual, - })), - deadline: s.deadline, - risk: s.risk, - provenance: rec.provenance, - slots: s.slots, - }, - ], + (s, i) => [hashes[i], metaFromUiStrategy(s, rec.provenance)], ); setCache((prev) => { diff --git a/packages/app/src/lib/join-book.ts b/packages/app/src/lib/join-book.ts index f573b3b..75e06b0 100644 --- a/packages/app/src/lib/join-book.ts +++ b/packages/app/src/lib/join-book.ts @@ -1,5 +1,6 @@ import type { Address, Hex } from "viem"; import type { Position, Provenance, RiskRating, SlotRow } from "./book"; +import type { UiStrategy } from "./compose/from-server"; /** * The pure JOIN: decoded on-chain positions (`/api/book`, which reads the @@ -141,3 +142,35 @@ export function pairFromTokens( .map((t) => t.symbol) .join(" / "); } + +/** + * One shipped strategy → the metadata this browser caches for it. + * + * The pair label comes from the strategy's OWN legs. It used to come from the + * whole selectable token list, which was indistinguishable from correct while + * that list held exactly two tokens and wrong the moment it held more — and + * strategies within one recommendation need not share a pair anyway. + */ +export function metaFromUiStrategy( + strategy: UiStrategy, + provenance: Provenance, +): CachedStrategyMeta { + return { + pair: pairFromTokens(strategy.legs.map((l) => l.token)), + templateLabel: strategy.templateShort, + description: strategy.description, + bandKind: strategy.bandKind, + band: strategy.band, + bandNote: strategy.bandNote, + legs: strategy.legs.map(({ token, virtual }) => ({ + token: token.address, + symbol: token.symbol, + decimals: token.decimals, + virtual, + })), + deadline: strategy.deadline, + risk: strategy.risk, + provenance, + slots: strategy.slots, + }; +} From ae8212343cb024d0e72ac8cded51420b335dd106 Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 04:59:58 +0100 Subject: [PATCH 07/11] App: availableTokens hides a token only on a confirmed zero The picker should offer what the wallet holds, but a balance has three states, not two. useTokenBalances returns undefined for a balance it has not observed, deliberately distinct from 0n, so hiding on undefined would let one failed RPC read empty the picker with nothing to act on. Only a read that succeeded and returned 0n hides a token. Returns three buckets so the picker's empty state can tell "you hold none of these" from "we could not read your balances". Also makes the app's tokenBySymbol case-insensitive, since the list now holds USDe and cbBTC. --- packages/app/src/lib/available-tokens.ts | 47 ++++++++++++++++++++++++ packages/app/src/lib/tokens.ts | 4 +- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/lib/available-tokens.ts diff --git a/packages/app/src/lib/available-tokens.ts b/packages/app/src/lib/available-tokens.ts new file mode 100644 index 0000000..71ec39a --- /dev/null +++ b/packages/app/src/lib/available-tokens.ts @@ -0,0 +1,47 @@ +import type { Address } from "viem"; +import type { TokenMeta } from "./compose/types"; + +/** + * Which of the offered tokens this wallet can actually compose with. + * + * A token is hidden ONLY on a confirmed zero — a balance read that succeeded + * and returned 0n. `useTokenBalances` returns `undefined` for a balance it has + * not observed (not read yet, or the read failed), deliberately distinct from + * `0n`, and that distinction is the whole point here: hiding on `undefined` + * would let one slow or failed RPC call empty the picker with nothing on screen + * to act on and no way to tell "you hold none of these" from "we don't know". + * + * Pure — no React, no wagmi, no clock. The three buckets exist so the picker + * can say which of those two situations it is in without re-deriving it. + */ +export type Availability = { + /** Confirmed above zero, or not yet observed. Rendered, in input order. */ + shown: TokenMeta[]; + /** Read succeeded and returned exactly 0n. The only hidden case. */ + hiddenZero: TokenMeta[]; + /** Not observed. A subset of `shown`, not an alternative to it. */ + unknown: TokenMeta[]; +}; + +export function availableTokens( + tokens: TokenMeta[], + balances: Record, +): Availability { + const shown: TokenMeta[] = []; + const hiddenZero: TokenMeta[] = []; + const unknown: TokenMeta[] = []; + + for (const token of tokens) { + const balance = balances[token.address]; + if (balance === undefined) { + shown.push(token); + unknown.push(token); + } else if (balance === 0n) { + hiddenZero.push(token); + } else { + shown.push(token); + } + } + + return { shown, hiddenZero, unknown }; +} diff --git a/packages/app/src/lib/tokens.ts b/packages/app/src/lib/tokens.ts index 8a688f9..31b4d1a 100644 --- a/packages/app/src/lib/tokens.ts +++ b/packages/app/src/lib/tokens.ts @@ -23,7 +23,7 @@ export function tokenBy(address: Address): TokenMeta | undefined { return TOKENS.find((t) => t.address.toLowerCase() === address.toLowerCase()); } -/** Display-side lookup (pair labels carry symbols, not addresses). */ +/** Display-side lookup (pair labels carry symbols, not addresses). Case-insensitive: the list holds mixed-case symbols (USDe, cbBTC). */ export function tokenBySymbol(symbol: string): TokenMeta | undefined { - return TOKENS.find((t) => t.symbol === symbol); + return TOKENS.find((t) => t.symbol.toLowerCase() === symbol.toLowerCase()); } From d2d4b524192f30497384e6f5c16296e8c2dca304 Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 05:00:29 +0100 Subject: [PATCH 08/11] App: a compose request is exactly two tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swapvm takes tokens: [string, string], MarketContext carries one pair and pairingPlan splits that one pair, so the composer has always been single-pair — invisibly, because the picker offered two tokens. With five offered, both bounds need stating: NEED_TWO_TOKENS for one selection, TOO_MANY_TOKENS for three or more. NO_TOKENS keeps meaning none, which is what compose-screen filters on. --- packages/app/src/lib/compose/request.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/app/src/lib/compose/request.ts b/packages/app/src/lib/compose/request.ts index bdef115..4ff506e 100644 --- a/packages/app/src/lib/compose/request.ts +++ b/packages/app/src/lib/compose/request.ts @@ -19,6 +19,8 @@ export type RequestIssue = { | "WRONG_CHAIN" | "EMPTY_PROMPT" | "NO_TOKENS" + | "NEED_TWO_TOKENS" + | "TOO_MANY_TOKENS" | "ZERO_AMOUNT" | "OVER_BALANCE" /** @@ -68,10 +70,26 @@ export function buildRecommendationRequest(draft: RequestDraft): BuildResult { }); } + // Exactly two. Every layer below is single-pair: swapvm takes + // tokens: [string, string], MarketContext carries one pair, and pairingPlan + // splits that one pair. A one-token budget also has no pair to derive, and + // full-range's price IS the ratio of the shipped amounts — so one token ships + // a position with no price. NO_TOKENS keeps meaning *none*: the screen filters + // on that code when a row is malformed. if (draft.selections.length === 0) { issues.push({ code: "NO_TOKENS", - message: "Select at least one token and set an amount.", + message: "Select two tokens and set their amounts.", + }); + } else if (draft.selections.length === 1) { + issues.push({ + code: "NEED_TWO_TOKENS", + message: "Pick a second token — a strategy is a pair.", + }); + } else if (draft.selections.length > 2) { + issues.push({ + code: "TOO_MANY_TOKENS", + message: "Two tokens per request — one strategy is one pair.", }); } From ef202dd4cf92d85fa25d0e276c50449c8cfb48d1 Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 05:01:14 +0100 Subject: [PATCH 09/11] App: extract the compose route's body validation so it can be tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route is the enforcement point — the token list and the two-token rule are server policy, and a disabled checkbox is an affordance. But the app's suite is tsx --test over pure modules with no route-handler harness, so validation written inline was validation nobody could test. parseComposeBody is that validation as a pure function, now covering the exactly-two-tokens rule alongside the existing address, duplicate and base-unit checks. --- packages/app/src/app/api/compose/route.ts | 65 ++---------------- packages/app/src/lib/compose/parse-body.ts | 79 ++++++++++++++++++++++ 2 files changed, 86 insertions(+), 58 deletions(-) create mode 100644 packages/app/src/lib/compose/parse-body.ts diff --git a/packages/app/src/app/api/compose/route.ts b/packages/app/src/app/api/compose/route.ts index 90a8a0f..7eba650 100644 --- a/packages/app/src/app/api/compose/route.ts +++ b/packages/app/src/app/api/compose/route.ts @@ -1,11 +1,7 @@ import { NextResponse } from "next/server"; -import type { Address } from "viem"; -import { - composeForApp, - type ServerBudgetEntry, -} from "@sluice/arbitration-sdk/serve"; +import { composeForApp } from "@sluice/arbitration-sdk/serve"; import { REQUEST_DEFAULTS } from "@/lib/compose/constants"; -import { tokenBy } from "@/lib/tokens"; +import { parseComposeBody } from "@/lib/compose/parse-body"; /** * The one key-bearing endpoint. The body carries only what the user chose @@ -20,9 +16,6 @@ export const runtime = "nodejs"; // plus the subgraph read. Vercel Hobby caps lower; Pro honours this. export const maxDuration = 60; -const ADDRESS = /^0x[0-9a-fA-F]{40}$/; -const BASE_UNITS = /^[0-9]+$/; - export async function POST(request: Request) { let body: unknown; try { @@ -30,59 +23,15 @@ export async function POST(request: Request) { } catch { return bad("body is not JSON"); } - const b = body as { - user?: unknown; - prompt?: unknown; - budget?: Array<{ address?: unknown; amount?: unknown }>; - }; - if (typeof b.user !== "string" || !ADDRESS.test(b.user)) { - return bad("user must be a 0x address"); - } - if (typeof b.prompt !== "string" || b.prompt.trim() === "") { - return bad("prompt must be a non-empty string"); - } - if (!Array.isArray(b.budget) || b.budget.length === 0) { - return bad("budget must be a non-empty array"); - } - - const budget: ServerBudgetEntry[] = []; - const seen = new Set(); - for (const entry of b.budget) { - if (typeof entry?.address !== "string" || !ADDRESS.test(entry.address)) { - return bad("budget entries need a 0x token address"); - } - const lower = entry.address.toLowerCase(); - if (seen.has(lower)) { - return bad(`duplicate token ${entry.address} in budget`); - } - seen.add(lower); - const meta = tokenBy(entry.address as Address); - if (!meta) { - return bad(`token ${entry.address} is not in the token list`); - } - if ( - typeof entry.amount !== "string" || - !BASE_UNITS.test(entry.amount) || - BigInt(entry.amount) === 0n - ) { - return bad( - `amount for ${meta.symbol} must be a positive base-unit integer string`, - ); - } - budget.push({ - address: meta.address, - symbol: meta.symbol, - decimals: meta.decimals, - amount: entry.amount, - }); - } + const parsed = parseComposeBody(body); + if (!parsed.ok) return bad(parsed.error); try { const result = await composeForApp({ - user: b.user, - prompt: b.prompt, - budget, + user: parsed.user, + prompt: parsed.prompt, + budget: parsed.budget, maxStrategies: REQUEST_DEFAULTS.maxStrategies, maxDeadlineSec: REQUEST_DEFAULTS.maxDeadlineSec, }); diff --git a/packages/app/src/lib/compose/parse-body.ts b/packages/app/src/lib/compose/parse-body.ts new file mode 100644 index 0000000..bcccb2e --- /dev/null +++ b/packages/app/src/lib/compose/parse-body.ts @@ -0,0 +1,79 @@ +import type { Address } from "viem"; +import type { ServerBudgetEntry } from "@sluice/arbitration-sdk/serve"; +import { tokenBy } from "../tokens"; + +/** + * The compose endpoint's body validation, as a pure function. + * + * This is the ENFORCEMENT point, not the picker: the token list and the + * two-token rule are server policy, and a disabled checkbox is an affordance. + * It lives here rather than inline in the route handler because the app's test + * suite is `tsx --test` over pure modules — there is no Next route-handler + * harness, so validation written inline is validation nobody can test. + * + * Limits (maxStrategies, deadlines, retries) are NOT read from the body. They + * are server policy in REQUEST_DEFAULTS. + */ +export type ParsedComposeBody = + | { ok: true; user: string; prompt: string; budget: ServerBudgetEntry[] } + | { ok: false; error: string }; + +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const BASE_UNITS = /^[0-9]+$/; + +export function parseComposeBody(body: unknown): ParsedComposeBody { + const b = body as { + user?: unknown; + prompt?: unknown; + budget?: Array<{ address?: unknown; amount?: unknown }>; + } | null; + + if (!b || typeof b !== "object") return bad("body must be a JSON object"); + if (typeof b.user !== "string" || !ADDRESS.test(b.user)) { + return bad("user must be a 0x address"); + } + if (typeof b.prompt !== "string" || b.prompt.trim() === "") { + return bad("prompt must be a non-empty string"); + } + if (!Array.isArray(b.budget)) return bad("budget must be an array"); + // One strategy is one pair, all the way down: swapvm takes two tokens, + // MarketContext names one pair, pairingPlan splits it. + if (b.budget.length !== 2) { + return bad(`budget must name exactly 2 tokens, got ${b.budget.length}`); + } + + const budget: ServerBudgetEntry[] = []; + const seen = new Set(); + for (const entry of b.budget) { + if (typeof entry?.address !== "string" || !ADDRESS.test(entry.address)) { + return bad("budget entries need a 0x token address"); + } + const lower = entry.address.toLowerCase(); + if (seen.has(lower)) return bad(`duplicate token ${entry.address} in budget`); + seen.add(lower); + + const meta = tokenBy(entry.address as Address); + if (!meta) return bad(`token ${entry.address} is not in the token list`); + + if ( + typeof entry.amount !== "string" || + !BASE_UNITS.test(entry.amount) || + BigInt(entry.amount) === 0n + ) { + return bad( + `amount for ${meta.symbol} must be a positive base-unit integer string`, + ); + } + + budget.push({ + address: meta.address, + symbol: meta.symbol, + decimals: meta.decimals, + amount: entry.amount, + }); + } + + return { ok: true, user: b.user, prompt: b.prompt, budget }; +} + +const bad = (error: string): ParsedComposeBody => ({ ok: false, error }); From 29af62e2c4d3ad4a7d967df854ff1542aabaf05f Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 05:03:06 +0100 Subject: [PATCH 10/11] App: the picker offers the tokens the wallet holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit available.shown replaces TOKENS everywhere the screen touched it, which also keeps a token that has just read as zero out of the budget — its row state would otherwise carry a token the user can no longer see. The picker gains the two-token cap, a footnote naming the tokens it hid so the composer does not look like it supports only what is on screen, and an empty state that distinguishes "you hold none of these" from "we could not read your balances". --- .../app/src/components/compose-screen.tsx | 26 +++++-- packages/app/src/components/token-picker.tsx | 76 ++++++++++++++++++- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/compose-screen.tsx b/packages/app/src/components/compose-screen.tsx index bbb9387..d77e85e 100644 --- a/packages/app/src/components/compose-screen.tsx +++ b/packages/app/src/components/compose-screen.tsx @@ -20,6 +20,7 @@ import { } from "@/lib/compose/from-server"; import type { TokenSelection } from "@/lib/compose/types"; import type { ServerComposeResult } from "@sluice/arbitration-sdk/serve"; +import { availableTokens } from "@/lib/available-tokens"; import { shipRecommendation } from "@/lib/ship"; import { TOKENS } from "@/lib/tokens"; import { useTokenBalances } from "@/lib/use-token-balances"; @@ -47,6 +48,14 @@ export function ComposeScreen() { const { data: walletClient } = useWalletClient(); const publicClient = usePublicClient(); const { balances, isLoading: balancesLoading } = useTokenBalances(address); + // The picker offers what this wallet can actually compose with. Hidden-on-zero + // is why `selections` must derive from `shown` and not from TOKENS: a token + // that drops to a confirmed zero after a refetch leaves the list, and its row + // state would otherwise keep it in the budget the user can no longer see. + const available = useMemo( + () => availableTokens(TOKENS, balances), + [balances], + ); // Ship needs both clients to sign/send; `walletClient` in particular // resolves asynchronously right after connecting, so there's a real (if // short) window where a validated recommendation exists but shipping would @@ -74,7 +83,7 @@ export function ComposeScreen() { const malformed = useMemo( () => - TOKENS.filter((t) => { + available.shown.filter((t) => { const row = rows[t.address]; return ( row?.selected && @@ -82,13 +91,13 @@ export function ComposeScreen() { parseAmount(row.input, t.decimals) === null ); }), - [rows], + [rows, available], ); // Malformed rows are excluded — there is no bigint to carry them in. const selections: TokenSelection[] = useMemo( () => - TOKENS.filter( + available.shown.filter( (t) => rows[t.address]?.selected && !malformed.some((m) => m.address === t.address), @@ -96,7 +105,7 @@ export function ComposeScreen() { token: t.address, amount: parseAmount(rows[t.address]?.input ?? "", t.decimals) ?? 0n, })), - [rows, malformed], + [rows, malformed, available], ); const built = useMemo( @@ -108,9 +117,9 @@ export function ComposeScreen() { prompt, selections, balances, - tokens: TOKENS, + tokens: available.shown, }), - [address, chainId, prompt, selections, balances], + [address, chainId, prompt, selections, balances, available], ); const issues: RequestIssue[] = useMemo(() => { @@ -235,7 +244,10 @@ export function ComposeScreen() { /> ; balances: Record; balancesLoading: boolean; @@ -42,6 +50,12 @@ export function TokenPicker({ // The first row-level problem, echoed in prose under the list. const rowError = derived.find((d) => d.problem)?.problem ?? null; + // Two tokens, and no more: one strategy is one pair, all the way down to + // swapvm's tokens: [string, string]. Checked rows stay clickable so swapping + // does not require clearing first. + const selectedCount = derived.filter((d) => d.row.selected).length; + const atCap = selectedCount >= 2; + const rendered = derived.map(({ token, row, balance, problem }) => { const bad = problem !== null; @@ -60,10 +74,11 @@ export function TokenPicker({ onChange(token.address, { ...row, selected: e.target.checked }) } - className="h-4 w-4 cursor-pointer accent-aqua" + className="h-4 w-4 cursor-pointer accent-aqua disabled:cursor-not-allowed disabled:opacity-30" /> @@ -136,11 +151,32 @@ export function TokenPicker({ and no further. Tokens never leave your wallet.

-
{rendered}
+ {tokens.length === 0 ? ( + + ) : ( +
{rendered}
+ )} + + {atCap && ( +

+ Two tokens per request — one strategy is one pair. +

+ )} - {rowError && ( -

{rowError}

+ {hiddenZero.length > 0 && tokens.length > 0 && ( +

+ {hiddenZero.length} supported{" "} + {hiddenZero.length === 1 ? "token" : "tokens"} hidden — you hold none + of {hiddenZero.length === 1 ? "it" : "them"} ( + {hiddenZero.map((t) => t.symbol).join(", ")}). +

)} + + {rowError &&

{rowError}

} ); } @@ -157,3 +193,35 @@ function exactInput(balance: bigint, decimals: number) { const fraction = s.slice(-decimals).replace(/0+$/, ""); return fraction ? `${whole}.${fraction}` : whole; } + +/** + * Nothing to offer — and which of three reasons it is, never a guess. + * + * "You hold none of these" and "we could not read your balances" are different + * statements, and only one of them is ever true at a time. Saying the first + * when the second is the case is the failure this whole filter is built to + * avoid. + */ +function EmptyPicker({ + isConnected, + hiddenZero, + unknown, +}: { + isConnected: boolean; + hiddenZero: TokenMeta[]; + unknown: TokenMeta[]; +}) { + const message = !isConnected + ? "Connect a wallet to see what you can compose with." + : unknown.length > 0 + ? "Could not read your balances — nothing is hidden, we just do not know yet. Check the RPC in the header." + : `None of the supported tokens are in this wallet: ${hiddenZero + .map((t) => t.symbol) + .join(", ")}.`; + + return ( +
+ {message} +
+ ); +} From 6ac5735b716b6999d874580f811083a661bf0de4 Mon Sep 17 00:00:00 2001 From: josevazf Date: Sun, 26 Jul 2026 05:06:03 +0100 Subject: [PATCH 11/11] Contracts: assert every offered token's decimals against the address book tokenList's decimals are what the compiler scales virtual amounts by, so a wrong value ships the wrong size rather than mis-rendering a number. USDT and cbBTC were never checked against the chain at all; USDe was checked only in StablePairStrategy. Also asserts the two mixed-case symbols, since that casing is what a toUpperCase() lookup gets wrong. --- contracts/test/ForkVenue.t.sol | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/contracts/test/ForkVenue.t.sol b/contracts/test/ForkVenue.t.sol index dd58044..12dc255 100644 --- a/contracts/test/ForkVenue.t.sol +++ b/contracts/test/ForkVenue.t.sol @@ -21,6 +21,12 @@ interface IERC5267 { ); } +/// @dev Just the metadata we assert. forge-std ships no IERC20Metadata. +interface IERC20Metadata { + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); +} + /// @title The venue is what we think it is /// @notice G1 from F1 §7 — the gate the whole venue rescope rests on. Everything else in /// F1 assumes real Aqua and SwapVM bytecode is reachable on a Base fork at the @@ -49,6 +55,9 @@ contract ForkVenueTest is Test { address internal router; address internal weth; address internal usdc; + address internal usde; + address internal usdt; + address internal cbbtc; uint256 internal forkBlock; function setUp() public { @@ -57,6 +66,9 @@ contract ForkVenueTest is Test { router = vm.parseJsonAddress(cfg, ".swapVMRouter"); weth = vm.parseJsonAddress(cfg, ".tokens.WETH"); usdc = vm.parseJsonAddress(cfg, ".tokens.USDC"); + usde = vm.parseJsonAddress(cfg, ".tokens.USDe"); + usdt = vm.parseJsonAddress(cfg, ".tokens.USDT"); + cbbtc = vm.parseJsonAddress(cfg, ".tokens.cbBTC"); forkBlock = vm.parseJsonUint(cfg, ".forkBlock"); vm.createSelectFork(vm.envOr("SLUICE_RPC_URL", PUBLIC_BASE_RPC), forkBlock); @@ -109,4 +121,24 @@ contract ForkVenueTest is Test { assertEq(name, "AquaSwapVMRouter", "not the Aqua-flavoured router - the instruction set would differ"); assertEq(verifyingContract, router, "EIP-712 domain does not bind to this address"); } + + /// @notice Every token the composer offers is real, and its decimals are what tokenList says. + /// @dev Not cosmetic: the compiler scales virtual amounts by these decimals, so a wrong + /// value here ships the wrong size, and identical bytes with different amounts hash + /// the same (F1 §2). Symbols are mixed-case on purpose — USDe and cbBTC are not + /// all-caps, which is exactly what a toUpperCase() lookup gets wrong. + function test_offeredTokensMatchTheTokenList() public view { + assertEq(IERC20Metadata(weth).decimals(), 18, "WETH is not 18dp"); + assertEq(IERC20Metadata(usdc).decimals(), 6, "USDC is not 6dp"); + assertEq(IERC20Metadata(usde).decimals(), 18, "USDe is not 18dp"); + assertEq(IERC20Metadata(usdt).decimals(), 6, "USDT is not 6dp"); + assertEq(IERC20Metadata(cbbtc).decimals(), 8, "cbBTC is not 8dp"); + + assertGt(usde.code.length, 0, "no code at USDe"); + assertGt(usdt.code.length, 0, "no code at USDT"); + assertGt(cbbtc.code.length, 0, "no code at cbBTC"); + + assertEq(IERC20Metadata(usdt).symbol(), "USDT", "USDT symbol mismatch"); + assertEq(IERC20Metadata(cbbtc).symbol(), "cbBTC", "cbBTC symbol mismatch"); + } }