Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions config/addresses.8453.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.<SYMBOL> 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.<SYMBOL> 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": [
{
Expand All @@ -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)."
}
32 changes: 32 additions & 0 deletions contracts/test/ForkVenue.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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");
}
}
65 changes: 7 additions & 58 deletions packages/app/src/app/api/compose/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,69 +16,22 @@ 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 {
body = await request.json();
} 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<string>();
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,
});
Expand Down
26 changes: 19 additions & 7 deletions packages/app/src/components/compose-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -89,29 +98,29 @@ export function ComposeScreen() {

const malformed = useMemo(
() =>
TOKENS.filter((t) => {
available.shown.filter((t) => {
const row = rows[t.address];
return (
row?.selected &&
row.input.trim() !== "" &&
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),
).map((t) => ({
token: t.address,
amount: parseAmount(rows[t.address]?.input ?? "", t.decimals) ?? 0n,
})),
[rows, malformed],
[rows, malformed, available],
);

const built = useMemo(
Expand All @@ -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(() => {
Expand Down Expand Up @@ -272,7 +281,10 @@ export function ComposeScreen() {
/>

<TokenPicker
tokens={TOKENS}
tokens={available.shown}
hiddenZero={available.hiddenZero}
unknown={available.unknown}
isConnected={isConnected}
rows={rows}
balances={balances}
balancesLoading={balancesLoading && isConnected}
Expand Down
76 changes: 72 additions & 4 deletions packages/app/src/components/token-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ export type PickerRow = { selected: boolean; input: string };

export function TokenPicker({
tokens,
hiddenZero,
unknown,
isConnected,
rows,
balances,
balancesLoading,
onChange,
}: {
tokens: TokenMeta[];
/** Read as exactly zero — named so the user knows why the list is short. */
hiddenZero: TokenMeta[];
/** Not observed. Shown, but the empty state must not blame the wallet. */
unknown: TokenMeta[];
isConnected: boolean;
rows: Record<Address, PickerRow>;
balances: Record<Address, bigint | undefined>;
balancesLoading: boolean;
Expand Down Expand Up @@ -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;

Expand All @@ -60,10 +74,11 @@ export function TokenPicker({
<input
type="checkbox"
checked={row.selected}
disabled={atCap && !row.selected}
onChange={(e) =>
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"
/>
<span className="flex items-center gap-2.5">
<TokenIcon address={token.address} symbol={token.symbol} size={28} />
Expand Down Expand Up @@ -136,11 +151,32 @@ export function TokenPicker({
and no further. Tokens never leave your wallet.
</p>

<div className="flex flex-col gap-2.5">{rendered}</div>
{tokens.length === 0 ? (
<EmptyPicker
isConnected={isConnected}
hiddenZero={hiddenZero}
unknown={unknown}
/>
) : (
<div className="flex flex-col gap-2.5">{rendered}</div>
)}

{atCap && (
<p className="mt-3 text-[11.5px] text-muted-2">
Two tokens per request — one strategy is one pair.
</p>
)}

{rowError && (
<p className="mt-3 text-xs text-danger">{rowError}</p>
{hiddenZero.length > 0 && tokens.length > 0 && (
<p className="mt-2 text-[11.5px] text-muted-2">
{hiddenZero.length} supported{" "}
{hiddenZero.length === 1 ? "token" : "tokens"} hidden — you hold none
of {hiddenZero.length === 1 ? "it" : "them"} (
{hiddenZero.map((t) => t.symbol).join(", ")}).
</p>
)}

{rowError && <p className="mt-3 text-xs text-danger">{rowError}</p>}
</section>
);
}
Expand All @@ -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 (
<div className="rounded-[10px] border border-dashed border-border px-4 py-6 text-center text-[12.5px] text-muted">
{message}
</div>
);
}
Loading