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.
+
}
);
}
@@ -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 {