Skip to content

Compose picker offers the tokens the wallet holds - #46

Merged
josevazf merged 12 commits into
mainfrom
feat/wallet-token-picker
Jul 26, 2026
Merged

Compose picker offers the tokens the wallet holds#46
josevazf merged 12 commits into
mainfrom
feat/wallet-token-picker

Conversation

@josevazf

Copy link
Copy Markdown
Member

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.ts called liveContext() with no pairTokens, so every request was described to the model as WETH/USDC no matter what the user selected. pairingPlan splits ctx.pair.pair and looks both symbols up in the budget — so any other pair returned null and 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

Configtokens, tokenList and chainlinkFeeds now hold identical symbol sets: WETH (18dp), USDC (6dp), USDe (18dp), USDT (6dp), cbBTC (8dp). They had already drifted — USDe was in tokens and missing from tokenList, so the G3-proven USDe/USDC demo pair could not be selected at all. config.test.ts enforces 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) and symbol()/name()/decimals() for USDT, cbBTC and USDe.

SDKcontext.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 in compileRecommendation and degrade to an empty shipInputs. pairTokensFor derives the pair from the budget in ascending-address order. tokenBySymbol is case-insensitive — it keyed on toUpperCase() and so could never find USDe or cbBTC, which reached users through compose --budget.

AppavailableTokens hides a token only on a confirmed zero: a read that succeeded and returned 0n. An unread or failed read keeps it visible, because undefined is not 0n and 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, and full-range's price is the ratio of the shipped amounts, so a one-token budget shipped a position with no price. Enforced at POST /api/compose, whose validation moved into a pure parseComposeBody so the enforcement point is testable at all.

Two defects the widening created

  • book.tsx built one pair label from pairFromTokens(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.midPrice is 3450, a WETH/USDC number that agreed with the pair label only by coincidence. Re-keying the label alone would have pairingPlan value-match a stable pair at 3450:1. A mid that does not belong to the named pair is now 0/deferred, which pairingPlan already rejects — the prompt loses the block rather than gaining wrong arithmetic.

Testing

SDK 197/197 · app 26/26 · contracts 16/16 (new test_offeredTokensMatchTheTokenList asserts all five tokens' decimals and symbols against the address book) · tsc --noEmit and eslint clean.

Two honest caveats:

  1. No new tests were added beyond the config-drift test — a deliberate call to move faster. availableTokens' three-state rule and parseComposeBody ship unverified, and there is no automated proof of the pairing-block regression fix; it is argued from the code, not demonstrated.
  2. The picker UI has not been exercised in a browser. Row filtering, the two-token cap, the hidden-token footnote and both empty states are typechecked and linted but need a funded wallet to verify.

Decisions are recorded on Notion: F1 §1 (token rule, USDe/USDC canonical string), F1 §5 (a request is one pair), F3 §5 (pair-from-budget, the stub-mid rule, the fifth feed), Wiring §6 (picker behaviour).

josevazf added 12 commits July 26, 2026 04:38
…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
@josevazf
josevazf requested a review from Copilot July 26, 2026 04:18
@josevazf
josevazf merged commit 7491160 into main Jul 26, 2026
1 check passed
@josevazf
josevazf deleted the feat/wallet-token-picker branch July 26, 2026 04:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 when opts.pairTokens is omitted. Since tokenBySymbol() now resolves mixed-case tokens from the full address book, compose-cli.ts can accept e.g. --budget USDe=... but it calls liveContext(parsed.maker) without passing pairTokens, 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.

Comment thread packages/arbitration-sdk/src/context.ts
Comment thread packages/app/src/lib/available-tokens.ts
Comment thread packages/app/src/lib/compose/parse-body.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants