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/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"); + } } 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/components/compose-screen.tsx b/packages/app/src/components/compose-screen.tsx index ae0c7b0..a230276 100644 --- a/packages/app/src/components/compose-screen.tsx +++ b/packages/app/src/components/compose-screen.tsx @@ -21,6 +21,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 { planShip, shipStrategies, type ShipPlan } from "@/lib/ship"; import { TOKENS } from "@/lib/tokens"; import { useTokenBalances } from "@/lib/use-token-balances"; @@ -48,6 +49,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 @@ -89,7 +98,7 @@ export function ComposeScreen() { const malformed = useMemo( () => - TOKENS.filter((t) => { + available.shown.filter((t) => { const row = rows[t.address]; return ( row?.selected && @@ -97,13 +106,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), @@ -111,7 +120,7 @@ export function ComposeScreen() { token: t.address, amount: parseAmount(rows[t.address]?.input ?? "", t.decimals) ?? 0n, })), - [rows, malformed], + [rows, malformed, available], ); const built = useMemo( @@ -123,9 +132,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(() => { @@ -272,7 +281,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} +
+ ); +} 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/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/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 }); 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.", }); } 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, + }; +} 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()); } 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"]); +}); diff --git a/packages/arbitration-sdk/src/context.ts b/packages/arbitration-sdk/src/context.ts index 4a1f46d..579e9e1 100644 --- a/packages/arbitration-sdk/src/context.ts +++ b/packages/arbitration-sdk/src/context.ts @@ -20,25 +20,48 @@ 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; +} + +/** + * 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). @@ -166,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 @@ -188,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"]; @@ -197,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; } } @@ -239,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.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", diff --git a/packages/arbitration-sdk/src/serve.ts b/packages/arbitration-sdk/src/serve.ts index ac35bbe..7cd29e4 100644 --- a/packages/arbitration-sdk/src/serve.ts +++ b/packages/arbitration-sdk/src/serve.ts @@ -14,7 +14,13 @@ 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, + stubPairFor, + type MarketContext, +} from "./context.ts"; import { FALLBACK_SOURCE, templateFallback, @@ -111,8 +117,13 @@ 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, ...stubPairFor(pairTokens) }; } // The validator clock. observedAt/observedBlock are SNAPSHOT facts the model @@ -158,13 +169,13 @@ 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 - // 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); @@ -220,9 +231,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 {