Compose picker offers the tokens the wallet holds - #46
Merged
Conversation
…tions 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.
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.
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.
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.
The map is the address book now, not a hardcoded literal.
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.
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.
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.
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.
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".
…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.
# Conflicts: # packages/app/src/components/compose-screen.tsx
There was a problem hiding this comment.
Pull request overview
Fixes the compose budget picker and request context so the user can only compose with supported tokens their wallet actually holds, while expanding the supported token universe to a pinned set of 5 Chainlink-USD-priced tokens. This aligns the app, SDK compilation, and market-context/pairing logic so non-WETH/USDC pairs remain correctly represented through compose → prompt → compile/ship.
Changes:
- Expand the pinned token + Chainlink feed set to
{WETH, USDC, USDe, USDT, cbBTC}and add tests/assertions to prevent config drift. - Fix SDK context construction: remove hardcoded token literals, make symbol lookup case-insensitive, derive request pair from budget, and pass pair tokens into
liveContext()/stubs so pairing logic stays reachable. - Update the app compose flow: hide tokens only on confirmed-zero balances, enforce exactly-two-token budgets (UI + server), and derive cached strategy pair labels from each strategy’s own legs.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/arbitration-sdk/src/serve.ts | Passes request-derived pairTokens into liveContext() and stub context to avoid defaulting every request to WETH/USDC. |
| packages/arbitration-sdk/src/serve.test.ts | Updates fallback/unknown-token test commentary to match the new address-book-driven token source. |
| packages/arbitration-sdk/src/context.ts | Derives TOKENS from the address book, adds case-insensitive tokenBySymbol, adds pairTokensFor, and re-keys stub pairs to request tokens. |
| packages/arbitration-sdk/src/config.test.ts | New tests enforcing tokens/tokenList/chainlinkFeeds symbol-set invariants and basic metadata sanity. |
| packages/arbitration-sdk/package.json | Adds config.test.ts to the SDK test suite. |
| packages/app/src/lib/tokens.ts | Makes display-side tokenBySymbol case-insensitive for mixed-case symbols. |
| packages/app/src/lib/join-book.ts | Adds metaFromUiStrategy() so strategy cache metadata (incl. pair label) comes from strategy legs. |
| packages/app/src/lib/compose/request.ts | Enforces exactly-two-token selection semantics in request building with new issue codes/messages. |
| packages/app/src/lib/compose/parse-body.ts | New pure server-side compose body parser enforcing exactly two supported tokens and positive base-unit amounts. |
| packages/app/src/lib/book.tsx | Uses metaFromUiStrategy() to store accurate per-strategy pair labels when recording shipped strategies. |
| packages/app/src/lib/available-tokens.ts | New pure function bucketing tokens into shown/hidden-zero/unknown based on 3-state balance reads. |
| packages/app/src/components/token-picker.tsx | Updates picker UI for hidden-zero + unknown states and caps selection at exactly two tokens. |
| packages/app/src/components/compose-screen.tsx | Wires availableTokens() into selection derivation so hidden tokens can’t linger in the budget, and passes new props into the picker. |
| packages/app/src/app/api/compose/route.ts | Replaces inline validation with parseComposeBody() and enforces the 2-token rule server-side. |
| contracts/test/ForkVenue.t.sol | Adds assertions that offered tokens exist and decimals/symbols match expectations (compiler sizing depends on decimals). |
| config/addresses.8453.json | Expands and documents the pinned token set, tokenList metadata, and Chainlink feeds (incl. USDe). |
Comments suppressed due to low confidence (1)
packages/arbitration-sdk/src/context.ts:244
liveContext()still defaults the pair to WETH/USDC whenopts.pairTokensis omitted. SincetokenBySymbol()now resolves mixed-case tokens from the full address book,compose-cli.tscan accept e.g.--budget USDe=...but it callsliveContext(parsed.maker)without passingpairTokens, so the prompt context remains mislabeled as WETH/USDC and can reintroduce the pairing-block regression for non-WETH/USDC pairs.
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"];
const fetched = await fetchPairContext(t0, t1);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The budget picker offered WETH and USDC whether or not the wallet held either. Widening it to the five tokens that have a pinned Chainlink USD feed, and showing only the ones the wallet actually holds.
The bug this fixes
serve.tscalledliveContext()with nopairTokens, so every request was described to the model as WETH/USDC no matter what the user selected.pairingPlansplitsctx.pair.pairand looks both symbols up in the budget — so any other pair returnednulland the pairing block vanished from the prompt, taking with it the value-matching arithmetic that exists specifically because a 7B model gets it wrong. Unreachable before only because the picker could not produce another pair; widening it made it reachable, so the fix is part of the same change.What changed
Config —
tokens,tokenListandchainlinkFeedsnow hold identical symbol sets: WETH (18dp), USDC (6dp), USDe (18dp), USDT (6dp), cbBTC (8dp). They had already drifted — USDe was intokensand missing fromtokenList, so the G3-proven USDe/USDC demo pair could not be selected at all.config.test.tsenforces the invariant now instead of a "keep these in sync" comment.Every on-chain value was read from real Base, not recalled: the USDe/USD feed (
0x790181e9…,description()=USDe / USD, 8dp, answer ≈ $0.99987) andsymbol()/name()/decimals()for USDT, cbBTC and USDe.SDK —
context.ts's second hardcoded token map is gone (it read the address book instead), so F1 §1's "nothing reads a chain-specific value from anywhere else" holds again; a budget token outside that literal used to throw incompileRecommendationand degrade to an emptyshipInputs.pairTokensForderives the pair from the budget in ascending-address order.tokenBySymbolis case-insensitive — it keyed ontoUpperCase()and so could never findUSDeorcbBTC, which reached users throughcompose --budget.App —
availableTokenshides a token only on a confirmed zero: a read that succeeded and returned0n. An unread or failed read keeps it visible, becauseundefinedis not0nand one slow RPC call must not empty the picker with nothing on screen to act on. Selection is capped at exactly two — every layer below is single-pair, andfull-range's price is the ratio of the shipped amounts, so a one-token budget shipped a position with no price. Enforced atPOST /api/compose, whose validation moved into a pureparseComposeBodyso the enforcement point is testable at all.Two defects the widening created
book.tsxbuilt one pair label frompairFromTokens(TOKENS)— the whole token list — and stamped it on every shipped strategy. Correct at two entries;"WETH / USDe / cbBTC / USDC / USDT"at five. Now derived per strategy from its own legs.STUB_PAIR.midPriceis 3450, a WETH/USDC number that agreed with the pair label only by coincidence. Re-keying the label alone would havepairingPlanvalue-match a stable pair at 3450:1. A mid that does not belong to the named pair is now0/deferred, whichpairingPlanalready rejects — the prompt loses the block rather than gaining wrong arithmetic.Testing
SDK 197/197 · app 26/26 · contracts 16/16 (new
test_offeredTokensMatchTheTokenListasserts all five tokens' decimals and symbols against the address book) ·tsc --noEmitandeslintclean.Two honest caveats:
availableTokens' three-state rule andparseComposeBodyship unverified, and there is no automated proof of the pairing-block regression fix; it is argued from the code, not demonstrated.Decisions are recorded on Notion: F1 §1 (token rule,
USDe/USDCcanonical string), F1 §5 (a request is one pair), F3 §5 (pair-from-budget, the stub-mid rule, the fifth feed), Wiring §6 (picker behaviour).