diff --git a/tools/CLAUDE.md b/tools/CLAUDE.md index 6c6141037..8e4b107b8 100644 --- a/tools/CLAUDE.md +++ b/tools/CLAUDE.md @@ -57,9 +57,9 @@ See [`tools/hindsight/CLAUDE.md`](hindsight/CLAUDE.md) for the full module overv Four subcommands via `cargo run -p hindsight --release --`: -- **`decode`** — Fetch block receipts, match and trace solver transactions, emit decoded trades - (token in/out, amounts, client, solver, settled gas). Accepts `--block N`, `--range START-END` - (max 1000 blocks), or defaults to the latest block. +- **`decode`** — Fetch a block's receipts and traces, match solver transactions, emit decoded + trades (token in/out, amounts, venue, solver, decode tier). Accepts `--block N`, + `--range START-END` (max 1000 blocks), or defaults to the latest block. - **`verify`** — Diff decoded trades against Allium's `aggregator_trades` ground truth. Requires `ALLIUM_API_KEY` and `ALLIUM_QUERY_ID`. - **`monitor`** — Live mode: drives an in-process `fynd-core` solver block-by-block, solving diff --git a/tools/hindsight/CLAUDE.md b/tools/hindsight/CLAUDE.md index 03713c756..661de1663 100644 --- a/tools/hindsight/CLAUDE.md +++ b/tools/hindsight/CLAUDE.md @@ -16,8 +16,8 @@ and takes neither. Built-in address books: `ethereum`, `base`, `unichain`, `arbitrum`, `bsc`, `polygon`, `robinhood`. Any other name needs `--registry`. -- **`decode`** — Fetch block receipts, match solver transactions, trace each one, and emit decoded - trades (token in/out, amounts, venue, solver, gas, sandwich evidence). Accepts `--block N`, +- **`decode`** — Fetch a block's receipts and traces (two calls), match, and emit decoded + trades (token in/out, amounts, venue, solver, sandwich evidence). Accepts `--block N`, `--range START-END` (max 1000 blocks), or defaults to the latest block. Use `--json` for machine-readable output. @@ -45,7 +45,7 @@ Any other name needs `--registry`. | Variable | Purpose | |---|---| -| `RPC_URL` | Chain JSON-RPC endpoint (must support `debug_traceTransaction`) | +| `RPC_URL` | Chain JSON-RPC endpoint (must support `debug_traceBlockByNumber`) | | `ALLIUM_API_KEY` | Allium API key (`verify` only) | | `ALLIUM_QUERY_ID` | Saved Allium query ID (`verify` only) | | `HINDSIGHT_REGISTRY` | Override path for the decoder address-book TOML | @@ -54,42 +54,51 @@ Any other name needs `--registry`. ### Decode pipeline (`src/decoder/`) -Match → trace → decode → veto → record. +Three steps per block: trace the whole block → decode each transaction from the solver's side → +attribute. [README.md](README.md) holds the full pipeline diagram and the two-tier decode model. | File/dir | Purpose | |---|---| -| `matching.rs` | Receipt-only filter: is this transaction a solver trade at all, plus match-time vetoes | -| `decode.rs` | The `TradeDecoder` trait, the matched entity → decoders mapping, `DecodeContext`, `TraderFlow` | -| `netting_decoders.rs` | Netting toolkit (`sender_flow`, `venue_flow`) plus the `SenderNetting` decoder | +| `mod.rs` | The orchestrator: fetch receipts + block trace, match, decode (declared first, netting fallback), veto, attribute | +| `declared.rs` | The declared decode: the settling solver frame's own calldata, anchored by the recipient's ledger receipt | +| `netting.rs` | The netting fallback (marked `decode: "netted"`): the engine plus the venue/sender/intent arms picked by the entry point | +| `attribution.rs` | Solver attribution tiers and venue fingerprints (owner, appData, fee wallet in address order, integrator tag) | | `transfer_ledger.rs` | Builds a transfer ledger from logs and native ETH flows | | `veto.rs` | The shared `Veto` type, plus post-decode vetoes of non-comparable shapes (NFT purchases, mis-paired wrap trades, fee-on-transfer skims) | -| `registry.rs` | Per-chain address book, loaded from TOML (see below) | +| `registry.rs` | Per-chain address book, loaded from TOML; joins each solver to its `SolverDecoder` at load | | `sandwich.rs` | Flags trades bracketed by a front/back attacker pair (see the design spec) | -| `venues/` | Per-venue `TradeDecoder` impls (Relay, MetaMask, Rabby), listed in `venues::decoders_for`. Relay has two, tried in order: `RelayCalldata` (calldata-primary, see below) then `RelayNetting` (the fallback) | -| `solvers/` | Per-solver knowledge: embedded quotes, match-time vetoes, attribution, and swap intents (`fly.rs`'s packed-calldata parser, `kyberswap.rs`'s ABI-decoded `swap` params, `zeroex.rs`'s ABI-decoded `AllowanceHolder.exec`/`Settler.execute`) recovered from a solver frame's own calldata, plus the declared output recipient (`output_recipient`) that lets `RelayCalldata` anchor the settled amount | -| `intents/` | Intent-role decoders (solver-sent, trader-not-sender): `cow.rs` reads CoW's `Trade` event, `netting.rs` is the generic net-flow finder, `decoders_for` lists them | -| `trace.rs` | Transaction trace fetching and processing | +| `solvers/` | One `SolverDecoder` per solver with code: declared swaps from calldata (`fly.rs` packed, `kyberswap.rs` ABI `swap` params, `zeroex.rs` `AllowanceHolder.exec`/`Settler.execute`, `oneinch.rs` v6 `swap`) or from logs (`okx.rs` `OrderRecord`, `cow.rs` `Trade`), plus `lifi.rs`'s bridge veto and the `venue_fingerprint` reads (`lifi.rs`'s integrator tag, `cow.rs`'s `appData` hash) | +| `trace.rs` | Whole-block trace fetching (`debug_traceBlockByNumber`) and frame walks | `src/verify/` contains the Allium integration: - `allium.rs` — Allium API client for the `verify` subcommand - `mod.rs` — Diff logic between decoded trades and Allium ground truth -Three address tiers: **venue** (order-flow owner, `tx.to`), **solver** (router that settled the -trade), **liquidity venues** (pools inside traces — not modeled here). +Three address tiers: **venue** (order-flow owner, `tx.to` — pure data), **solver** (router that +settled the trade — the only tier with code), **liquidity venues** (pools inside traces — not +modeled here). + +### The two decode tiers + +Every record carries `decode: "declared" | "netted"`. Declared records (solver calldata or a +batch settler's `Trade` log) are the trusted tier and the report's default scope. Netted records +(balance netting) can hide an unaccounted fee inside the amounts; the report excludes them unless +`--include-netted`. ### The address book (`registry/.toml`) All chain- and protocol-specific data lives in a per-chain TOML, embedded for the seven chains listed above (`registry::BUILTIN_CHAINS`) and loadable via `--registry`. A book carries only the -tiers its chain has — Unichain has no batch settlers because CoW does not settle there, and no LiFi -or integrator tier because the Diamond is not deployed; each book's header says what was checked. -An address also moves per chain: Robinhood Chain's LiFi Diamond, 0x Settler, OKX router and -MetaMask router all sit at chain-specific addresses, re-derived rather than copied. -Sections: `wrapped_native`, `infrastructure` (Permit2 etc. — -addresses attribution and sandwich detection skip), `usd_stablecoins` (USD anchors for -reporting), `batch_settlers`, `[solvers]` (router address → name), `[labels]` (display-only -names), and `[venues.]` (entry points, fee collectors, and — for venues that declare -their solver in calldata — `solver_aliases`). +tiers its chain has; each book's header says what was checked. An address also moves per chain: +Robinhood Chain's LiFi Diamond, 0x Settler, OKX router and MetaMask router all sit at +chain-specific addresses, re-derived rather than copied. Sections: `wrapped_native`, +`infrastructure` (Permit2 etc.), `usd_stablecoins` (USD anchors for reporting), `batch_settlers`, +`[solvers]` (router address → name; the name joins to a `SolverDecoder` at load), `[labels]` +(display-only names), `[venues.]` (entry points), and the venue fingerprints +(`[venue_owners]`, `[venue_fees]`, `[venue_integrators]`, `[venue_appdata]`). A fee paid to a +`[venue_fees]` wallet is corrected out of the amounts (`attribution::venue`): subtracted from +`amount_in` when the wallet took the sell token, added back to `amount_out` when it took the buy +token. Every other venue fee is left inside the amounts. ### Re-solve engine (`src/resolve/`) @@ -132,30 +141,32 @@ positive-only USD histogram (`hindsight_positive_slippage_usd`) whose sum is the hypothetical revenue. Absent when the top was unsolved or the re-execution failed (e.g. a pool vanished at N). -### Calldata-first Relay decoding - -`RelayCalldata` reads `token_in`/`token_out`/`amount_in` from the settling solver frame's own -`SwapIntent` and recovers the settled `amount_out` as the gross amount of `token_out` received by -the output recipient the same calldata declares — the one field calldata can never carry. Two -guards protect the recipient-receipt query: the recovered output must clear the intent's -`min_amount_out` floor, and any declared quote must sit within `plausible_quote`'s band of it; -either failure falls through to `RelayNetting`. The solver frame's `amount_in` needs no fee -adjustment — Relay pays its input-side fee to the collector *before* forwarding into the solver -call, so it is already the post-fee figure `amount_in` is defined to be — and the recipient's -receipt is the gross output before any output-side fee, so neither amount needs adjusting; both -fees are still recorded via `venue_fee_in`/`venue_fee_out` for transparency. See -`.claude/plans/calldata-first-decoding.md` for the empirics: on a 315-transaction Base sample, -coverage rises from 60.0% (netting alone) to 91.4% (calldata-first union), with zero divergences -across the 165 trades both paths could decode. +### The declared decode + +`declared_flow` reads `token_in`/`token_out`/`amount_in` from the settling solver frame's own +`DeclaredSwap` and recovers the settled `amount_out` as the gross amount of `token_out` received by +the output recipient the same calldata declares (falling back to the transaction sender) — the +one field calldata can never carry. Two guards protect the recipient-receipt query, and they act +differently: a recovered output below the declared `min_amount_out` floor drops the transaction +(`Veto::OutputNotFound`) rather than falling through to netting, because once a solver has stated +the trade, netting would answer a different question; a declared quote outside `plausible_quote`'s +band drops only the quote and keeps the trade. Dropped transactions are counted by +`hindsight_vetoed_transactions_total`, labelled by veto. A fee paid +to a `[venue_fees]` wallet is corrected out of both amounts by `attribution::venue`, on whichever +side the wallet was paid; no other venue fee is modelled (see the README). + +Declared reads run first and netting is the marked fallback, which is what the two-tier model +above describes. ### Key types -- `DecodedTrade` — decoded on-chain trade; amounts are venue-fee-adjusted so re-solve compares - like-for-like. Carries `sandwich` evidence when a bracket pair was found, and `min_amount_out`, +- `DecodedTrade` — decoded on-chain trade. A fee paid to a `[venue_fees]` wallet is corrected out + of the amounts so re-solve compares like-for-like; every other venue fee stays inside them. + Carries `sandwich` evidence when a bracket pair was found, and `min_amount_out`, `declared_quote`, and `quote_timestamp` (the calldata-declared terms copied off the settling - solver's `SwapIntent`, when one was recovered). -- `RangeComparison` — a trade solved at top and back, including gas-netted settled output and - the top route's `Slippage` between the two states (from its re-execution at back). + solver's `DeclaredSwap`, when one was recovered). +- `RangeComparison` — a trade solved at top and back, plus the top route's `Slippage` between + the two states (from its re-execution at back). All comparisons are gross of gas. - `Outcome` — `Solved`, `Partial`, or `Unsolvable`. - `SolvedAmount` — a solved state's amounts plus `algorithm` (which worker pool won the quote) and `solved_route` (the full `fynd_core::types::Route`, kept in memory to replay at back-of-block). @@ -199,27 +210,21 @@ It surfaces three ways: - **JSONL**: flat `algorithm` and `route` per state, next to the nested per-hop route (which keeps the pools and amounts the string leaves out). -## Adding a venue / solver / decoder / chain - -- **Solver** (a router Fynd competes with): one line in the address book's `[solvers]` section is - enough for matching, attribution, gas isolation, and metric labels. Optional code: a - `SolverKnowledge` impl in `solvers/` (registered in `solvers::IMPLEMENTATIONS`) with a - `solver_veto` method if some of its orders are not same-chain swaps, or a `swap_intent` method - if a trade's terms (tokens, amounts, on-chain floor, and — when the calldata declares one — - the solver's off-chain quote) can be recovered from the settling solver frame's own calldata. -- **Venue** (a platform users enter through): a `[venues.]` address-book section plus a - `TradeDecoder` in `venues/`, registered in the one `venues::decoders_for` arm (its `mod` - declaration is the only other line). Most venues are sender netting + fee back-out — call - `netting_decoders::venue_flow` and add only what is specific to the venue. The registry fails to load if - an address-book venue has no decoder. -- **Decoder** (a new way to read a swap — calldata decoding, log parsing): a `TradeDecoder`, with - its extraction toolkit in `netting_decoders`/`calldata`, listed in the mapping for the entities that use - it. Netting is one shared engine; calldata is per-router, so a calldata decoder is a standalone - parser. -- **Chain**: a new `registry/.toml` plus its entry in `registry::BUILTIN_CHAINS`, or passed - via `--registry`. Verify each venue's fee collector on that chain before adding it — a missing - collector leaves the fee inside the amounts, which is a wrong record rather than a miss. Check - the monitor's pacing flags (`--max-lag-blocks`) against the chain's block time. The `verify` +## Adding a solver / venue / chain + +- **Solver** (a router Fynd competes with): one line in the address book's `[solvers]` section + covers matching, attribution, and metric labels. To make its trades declared (trusted) instead + of netted: a `SolverDecoder` impl in `solvers/` with a `declared` method, registered as one row + in `solvers::IMPLEMENTATIONS`. One parse fills the whole `DeclaredSwap`, including the output + recipient when the calldata declares one. Return `Err(Veto)` from the same method if some of its + orders are not same-chain swaps. Add `venue_fingerprint` too if its own data names the frontend + that built the order; both methods are reached through `Registry::solver`, so adding either + needs no edit outside `solvers/`. +- **Venue** (a platform users enter through): a `[venues.]` address-book section — its + entry points. No code. A venue with no entry point of its own is identified by its fee wallet + (`[venue_fees]`), its `appData` hash, or its integrator tag instead. +- **Chain**: a new `registry/.toml` plus its entry in `registry::BUILTIN_CHAINS`, or + passed via `--registry`. Check the monitor's pacing flags (`--max-lag-blocks`) against the chain's block time. The `verify` subcommand's saved Allium query is per-chain. ## Running diff --git a/tools/hindsight/README.md b/tools/hindsight/README.md index 234580ef3..3ac1ea1ee 100644 --- a/tools/hindsight/README.md +++ b/tools/hindsight/README.md @@ -14,233 +14,197 @@ measurable value of adding Fynd to a venue. | `decode` | Decode the solver trades in a block or range and print/JSON them | | `verify` | Diff decoded trades against Allium's `aggregator_trades` ground truth (dev check) | | `monitor` | Live: drive an in-process Fynd solver block-by-block, re-solve every settled trade, emit JSONL + Prometheus metrics | +| `report` | Offline: render a monitor run's comparison JSONL into one HTML file | -All take `--chain` (selects the address book) and `--registry` / +The on-chain subcommands take `--chain` (selects the address book) and `--registry` / `HINDSIGHT_REGISTRY` to load a custom address book. See `--help` per subcommand and -[CLAUDE.md](CLAUDE.md) for environment variables. +[CLAUDE.md](CLAUDE.md) for environment variables. The RPC endpoint must support +`debug_traceBlockByNumber`. ## Terminology: the three address tiers - **Venue** — the platform the user entered through (`tx.to`): Relay, MetaMask. Owns the order - flow, picks a solver, may take a fee. + flow, picks a solver, may take a fee. A venue is pure address-book data; no code is written + per venue. - **Solver** — the router that computed and settled the route: 1inch, 0x, KyberSwap. These are - Fynd's competitors. + Fynd's competitors, and the only tier with code: a solver can have a `SolverDecoder`. - **Liquidity venue** — the pools a route executes against (Uniswap, Curve). Not modeled here; they only appear inside traces. ## Architecture +The pipeline is solver-first: a trade's authoritative terms live in the settling solver's own +call, so decoding starts there, and the venue is attributed afterwards as a label. + ### Decode pipeline (`src/decoder/`) ``` - eth_getBlockReceipts (one call per block) + eth_getBlockReceipts + debug_traceBlockByNumber (two RPC calls per block) │ ▼ - ┌─────────────────┐ solver_veto ┌─────────────────┐ - │ matching │ ───────────────▶ │ SolverKnowledge │ - └────────┬────────┘ skips non-swaps └─────────────────┘ - │ matched on tx.to or a solver log, then debug_traceTransaction - ▼ - ┌─────────────────┐ - │ gather evidence │ receipt + logs, trace, root calldata, transfer - └────────┬────────┘ ledger → one DecodeContext - │ + ┌─────────────────┐ keep a transaction when a known solver's frame is in its + │ match │ trace, its entry point is a known venue / solver / batch + └────────┬────────┘ settler, or a known solver emitted one of its logs; skip + │ everything else, never decoded. ▼ - ┌─────────────────┐ the matched entity maps to an ordered list of decoders, - │ decode │ each tried until one returns a TraderFlow (an entity may - └────────┬────────┘ list several — a richer source first, a general one as fallback): + ┌─────────────────┐ the settling solver's own declaration: + │ declared decode │ calldata — find the solver frame, ask its registry entry's + └────────┬────────┘ SolverDecoder for the declared swap and the output + │ recipient; anchor amount_out as the recipient's ledger + │ receipt (guards: the min_amount_out floor and the + │ plausible_quote band) + │ logs — a batch settler's single Trade event (CoW) │ - │ direct solver → [ SenderNetting ] - │ batch settler / solver → [ CowSettlement, IntentNetting ] - │ venue relay → [ RelayCalldata, RelayNetting ] - │ venue metamask → [ MetaMaskNetting ] - │ TraderFlow + │ declined ──▶ ┌──────────────────┐ net the balances instead, picked by + │ │ netting fallback │ the entry point: venue → sender + │ └────────┬─────────┘ netting; a batch settler + │ │ or solver log → find the trader in the + │ │ transfers; solver → sender netting. + │ │ Records are marked decode: "netted". + ▼ ▼ + ┌─────────────────┐ veto (reject non-trades) → venue attribution (entry point → + │ post-processing │ owner → appData → fee wallet → integrator tag) → solver + └────────┬────────┘ attribution (entry point → trace frame → guess) → + │ sandwich scan ▼ - ┌─────────────────┐ swap_intent ┌─────────────────┐ - │ post-processing │ ───────────────▶ │ SolverKnowledge │ - └────────┬────────┘ └─────────────────┘ - │ veto → venue attribution → solver attribution → gas → intent → sandwich scan - ▼ - DecodedTrade -``` - -Horizontal arrows are consultations: the stage calls the named `SolverKnowledge` method on the -solver's implementation. The stages themselves are protocol-agnostic. - -### Decoders - -A `TradeDecoder` turns one matched, traced transaction into the trader's flow. *How* it reads the -swap is open — the trait fixes only the input and the output, never the method. A decoder might -read the value movements, the calldata, the protocol's event logs, some combination of those, or -a source we have not needed yet; netting is simply the one that exists today. - -```rust -trait TradeDecoder

{ - fn name(&self) -> &'static str; - /// The trader's flow, or `None` when this decoder cannot read the transaction. - async fn decode(&self, ctx: &mut DecodeContext

) -> Option; -} - -// decode.rs — the matched entity selects its decoders -match role { - Sender => vec![Box::new(SenderNetting)], - Intent => intents::decoders_for(), // [CowSettlement, IntentNetting] - Venue(name) => venues::decoders_for(name), // e.g. "relay" → [RelayCalldata, RelayNetting] -} -``` - -``` - matched transaction - (tx.to = entry_point) - │ - ▼ - ┌─────────────────────────────────────┐ - │ Is entry_point a known VENUE? │ registry.venue_name(tx.to) - │ (Relay router, MetaMask router …) │ - └─────────────────────────────────────┘ - │ yes │ no - ▼ ▼ - TraderRole::Venue(name) ┌──────────────────────────────┐ - │ │ Is entry_point a BATCH SETTLER│ is_batch_settler(tx.to) - │ │ (CoW settlement contract)? │ - │ └──────────────────────────────┘ - │ │ yes │ no - │ ▼ ▼ - │ TraderRole::Intent ┌───────────────────────┐ - │ │ │ Is entry_point KNOWN? │ is_known(tx.to) - │ │ │ (a registered router) │ - │ │ └───────────────────────┘ - │ │ │ yes │ no - │ │ ▼ ▼ - │ │ TraderRole::Sender TraderRole::Intent - │ │ │ │ - ▼ ▼ ▼ ▼ - venues::decoders_for(name) intents::decoders_for() SenderNetting intents::decoders_for() - │ └──── intents/ ────┘ netting_dec.rs └──── intents/ ────┘ - ┌──────────┴─────────────────┐ - ▼ ▼ - [RelayCalldata, RelayNetting] [MetaMaskNetting] - (venues/relay.rs) (venues/metamask.rs) - - direct call vs a solver-settled intent order — SAME solver, DIFFERENT decoder: - 0x called directly → Sender → [ SenderNetting ] (your own tx, your gas) - 0x settling your intent order → Intent → intents::decoders_for() (a solver settles for you) - an intent source with a richer signal gets its own decoder ahead of the netting fallback — - CoW reads its Trade event (intents/cow.rs), then IntentNetting catches the rest. Relay is the - same shape: RelayCalldata reads the settling solver's own calldata (SwapIntent) plus a - recipient-anchored ledger query for the settled output, ahead of RelayNetting. - - implement a new decoder where the entity that carries the flow lives: - ├─ new venue → venues/.rs + arm in venues::decoders_for("") - ├─ new read for an existing venue → another TradeDecoder in that venue's list (first-wins order) - └─ new intent source (a settler) → intents/.rs + entry in intents::decoders_for() - - the Intent list lives in intents::decoders_for() (first-wins order): - [ MyDecoder, IntentNetting ] # prepend: self-guard; netting stays the fallback - [ MyDecoder ] # full swap: no fallback — must cover every intent trade + DecodedTrade carries decode: "declared" or "netted" ``` -One transaction goes to one entity — a direct sender, an intent order, or a specific venue — and -that entity's decoders are tried in order, first hit wins. Every kind of evidence is gathered -once into the `DecodeContext`, so a decoder takes only what it needs and a decoder that declines -costs the next one nothing. What the common sources tell you — examples, not a fixed menu: +### The two decode tiers -| Evidence | What it tells you | -|---|---| -| Value movements: ERC-20 `Transfer` events + traced native transfers | What actually moved | -| Calldata: the transaction's input | What the transaction requested | -| Protocol event logs: a `Swap`/fill event | What the contract declared | +**Declared** (`decoder/declared.rs`, `decoder/solvers/cow.rs`): the trade as the settlement's own +data states it. Calldata carries `token_in`/`token_out`/`amount_in`/`min_amount_out` (and +sometimes the solver's off-chain quote); the one field it can never carry — the settled +`amount_out` — is anchored as the gross amount the declared output recipient received in the +transfer ledger. No venue knowledge is needed to decode: a `[venue_fees]` wallet's cut is +corrected out afterwards, by the step that knows which decoder produced the amounts (see below). +These are the trusted records, and the report's default scope. -A decoder may read one of these, several at once, or something else. +**Netted** (`decoder/netting.rs`): recover the swap from what moved — ERC-20 transfers plus +native flows from the trace. Works for any solver with no parser, but a fee the ledger does not +show (or whose collector is not in the address book) sits inside the amounts. Netted records are +marked (`decode: "netted"`) and excluded from the report unless `--include-netted`. -Example of a venue correction: on a MetaMask ETH→token swap, netting alone recovers "1000 ETH → -2000 TOKEN" — well-formed but wrong, because 9 of the 1000 went to MetaMask's fee wallet before -the swap. `MetaMaskNetting` backs the fee out to 991. +### SolverDecoder (`src/decoder/solvers/`) -### Solver knowledge (`solvers/`) - -What a solver's transactions reveal beyond its address — a calldata-recovered swap intent -(KyberSwap's ABI decode plus its `clientData` quote, ParaSwap's word layout, Fly's packed layout), -a match-time veto (LiFi's bridge orders), the integrator tag a frontend records in the solver's -event (LiFi's Diamond), or the fee recipients its calldata names (KyberSwap's `feeReceivers`). A -fee recipient read this way covers a frontend that has no address-book entry: the calldata says -who is paid, and the transfer ledger says how much, so the cut is added back and the settled -output stays gross. Every method defaults to "nothing to add", so most solvers are a single -address-book line with no code; those with code are registered in `solvers::IMPLEMENTATIONS`. +One trait per solver — everything the solver's own calldata and logs can say: ```rust -trait SolverKnowledge { - /// The trader's swap terms (token in/out, amounts, the on-chain min_amount_out floor, and — - /// when the calldata declares one — the solver's off-chain quote), when the solver frame's - /// own calldata carries them — how a reverted trade's floor is recovered (a revert emits no - /// logs to net a settled amount from). `amount_in_hint` is the decoded flow's input amount, - /// when known (absent for a reverted trade); scan-based extractors (ParaSwap) need it to - /// locate fields by value rather than by ABI offset. - fn swap_intent(&self, input: &[u8], amount_in_hint: Option) -> Option { None } - - /// The address this solver's calldata declares as the output recipient — how a - /// calldata-primary decode (RelayCalldata) learns whose receipt to read the settled amount - /// from, since calldata alone never carries a settled amount. - fn output_recipient(&self, input: &[u8]) -> Option

{ None } - - /// The veto this solver's logs place on a matched transaction that is not a swap. - fn solver_veto(&self, logs: &[Log]) -> Option { None } - - /// The order-flow integrator tag this solver records in its logs, when it exposes one. - fn integrator(&self, logs: &[Log]) -> Option { None } - - /// The fee recipients this solver's calldata names, for routers that let an integrator - /// take a cut of the swap. - fn fee_recipients(&self, input: &[u8]) -> Vec
{ Vec::new() } +trait SolverDecoder { + /// What this solver's own data says about the transaction: nothing (Ok(None)), the trade its + /// calldata or logs declare, or a veto — the transaction is not a same-chain swap and must + /// not be decoded at all. + fn declared(&self, input: &[u8], logs: &[Log]) -> Result, Veto>; + + /// The frontend tag this solver's data carries, for venues that share its router. + fn venue_fingerprint(&self, input: &[u8], logs: &[Log]) -> Option; +} + +enum VenueTag { + /// A frontend string the solver records in its own swap event (LiFi). + Integrator(String), + /// An order's committed appData hash (CoW). + AppData(B256), } ``` -### Venue attribution (`venue_attribution.rs`) - -The venue is normally the contract the trader entered through (`tx.to`). Some order-flow venues own -the flow without being that contract, so after a flow is decoded one step can override the venue from -a registry fingerprint. Nothing in `venue_attribution.rs` names a specific venue — it reads four maps -from the address book: - -- **owning trader** (`[venue_owners]`) — the flow was read from a known venue address (kpk's Safes, - surfaced from the CoW decoder's owner). -- **CoW appData tag** (`[venue_appdata]`) — the settled order committed a frontend tag (`appCode`) - whose appData hash maps to a venue (LlamaSwap). The hash is read from the settle calldata by - `intents::venue_tag`, so `venue_attribution.rs` stays protocol-agnostic. -- **fee wallet** (`[venue_fees]`) — a known venue fee wallet took the output-token fee (Phantom, - Robinhood); the fee is grossed back. Only inside an already-matched trade, so a dust spray to a - fee wallet is not mistaken for flow. +Two methods, one per question the decoder asks a solver. `declared` answers "what does this +solver say this transaction traded?", and a solver's parse fills the whole `DeclaredSwap` in one +pass, including the recipient. `venue_fingerprint` answers "does this solver's data name the +frontend that built the order?" — LiFi's integrator tag, CoW's `appData` hash. A solver's veto is +not a third method: it rides on `declared`'s `Err`, because a veto is a statement about the same +read. + +Both default to "this solver's data does not carry that", so most solvers need no code at all — +one address-book line covers matching, attribution, and labels. An implementation is one row in +`solvers::IMPLEMENTATIONS`, joined onto the registry's `Solver` entry when the address book loads; +at trade time every lookup is `registry.solver(address)`, never a name search. That dispatch is +why no caller names a solver module: `decode_transaction` asks the settling solver's entry for a +fingerprint without knowing which solver carries one. + +Today: Fly (packed calldata), KyberSwap (ABI `swap` params + `clientData` quote), 0x +(`AllowanceHolder.exec` / `Settler.execute`), ParaSwap (quote scan), 1inch (v6 `swap` +calldata), okx (`OrderRecord` log), LiFi (bridge veto + integrator tag), and CoW's `Trade`-log +read plus its `appData` tag. + +### Why decoding is per solver, not per venue + +One solver serves many venues: the same KyberSwap call settles a direct trade, a Relay trade, +and a MetaMask trade. Decoding by venue would re-implement the same read per venue — and could +give the same solver call different results depending on the wrapper. Decoding by solver reads +the call once, identically everywhere; the venue is looked up afterwards from the entry point +and the registry fingerprints. + +### Venue fees are not modelled + +A venue's fee is charged whichever solver fills the order, so it cancels out of the Fynd +comparison and hindsight does not track it. + +One correction survives, and it is not about the fee itself. Fynd quotes the swap alone, so when a +known fee wallet (`[venue_fees]`) is paid out of the trade the recorded amounts have to be put back +on the swap's own basis, on whichever side the wallet was paid: + +- **paid in the buy token** — the trader's receipt is short of the swap's gross output by the fee, + so the fee is added back into `amount_out`. Without it the comparison hands Fynd the venue's cut + as savings. Skipped when the recorded figure already contains the cut: a solver's own event + states the gross output outright, and a receipt measured at the router that then paid the fee + wallet is gross too. Adding it there would count the cut twice. +- **paid in the sell token** — the pools saw less than `amount_in` states, so the fee is + subtracted. Without it Fynd is re-solved on more input than reached the pools and its larger + output reads as savings. + +Both corrections apply on either tier, declared or netted. `decode_transaction` applies them +(`apply_venue_fee`), not the venue label search: it is the one step that knows which decoder +produced the amounts, and running it there means the correction does not depend on which +fingerprint happened to name the venue. A fee paid to any other address stays inside the amounts, +which is part of what the netted marker warns about. + +### Venue attribution (`attribution.rs`) + +The venue is normally the contract the trader entered through (`tx.to`). Some order-flow venues +own the flow without being that contract, so after a flow is decoded one step can override the +venue from a registry fingerprint. Nothing in `attribution.rs` names a specific venue — it reads +four maps from the address book: + +- **owning trader** (`[venue_owners]`) — the flow was read from a known venue address (kpk's + Safes, surfaced from the CoW decode's owner). +- **CoW appData tag** (`[venue_appdata]`) — the settled order committed a frontend tag + (`appCode`) whose appData hash maps to a venue (LlamaSwap). +- **fee wallet** (`[venue_fees]`) — a known venue fee wallet took a cut (Phantom, Robinhood, + Coinbase's Base App). `venue_fee` reports the cut and the caller corrects it out of the amounts + (see above); every function in `attribution.rs` only reads. Wallets are checked in address + order, so a trade cut by two venues' wallets resolves the same way on every run. - **provider integrator tag** (`[venue_integrators]`) — a provider's event carried an integrator - string mapped to a venue (LiFi frontends). The tag is read by that provider's - `SolverKnowledge::integrator`, so `venue_attribution.rs` stays provider-agnostic. + string mapped to a venue (LiFi frontends), read through `SolverDecoder::venue_fingerprint`. + +The solver label comes from its own evidence tiers, most- to least-trusted: the entry point +itself, the outermost solver frame in the trace, the largest external call (a guess, for unknown +routers), and the entry-point label as the honest "don't know". The tier is recorded on the record +(`solver_source`). A venue's own claim about which solver it routed to is not consulted — the +router that settled the trade is in the trace, which is the harder fact, and the `solver_aliases` +tables the claim was read from are gone from the address books. ### Per protocol, not per chain -A venue or solver deployed on several chains behaves the same everywhere, so one decoder serves -all of them; what differs per chain — entry points, router addresses, fee collectors, -stablecoins — lives in the per-chain address book. A venue that genuinely diverges on one chain -(a different router, a different ABI) is registered under its own section name (Relay on Base → -`[venues.relay_base]`) with its own decoder. +A venue or solver deployed on several chains behaves the same everywhere, so one `SolverDecoder` +serves all of them; what differs per chain — entry points, router addresses, stablecoins — lives +in the per-chain address book. -A wrong sameness assumption mostly surfaces as trades failing to decode or `verify` — but not -always: a diverged fee scheme, with the fee collector missing from that chain's book, decodes -trades with the fee still inside the amounts. Those are wrong records, not misses, so fee -collectors are re-verified on every chain a venue is added on. +A wrong sameness assumption surfaces as trades failing to decode, or as `verify` reporting gaps +against Allium. ### Where does new code go? | You want to… | Touch | Without it | |---|---|---| -| Track a new solver | One line in the address book's `[solvers]` section. No code — trades sent straight to the router then match on the entry point and decode like any other; the intent and veto rows below are optional extras | Trades sent directly to the solver's router never match, so they never appear in the output; trades a known venue routed through it still decode, but the solver is recorded as "unknown" | -| Recover a solver's swap terms (tokens, amounts, on-chain floor, and — when its calldata declares one — its off-chain quote) | A `swap_intent` method on its `SolverKnowledge` impl, dispatched with the settling solver frame's own input | A trade's record carries no `min_amount_out` / `declared_quote` / `quote_timestamp` | -| Skip a solver's non-swap orders | A `solver_veto` method on its `SolverKnowledge` impl | Those orders decode as trades that never happened, with absurd rates | -| Add a venue | A `[venues.]` section in the address book, a `TradeDecoder` in `venues/`, one arm in `venues::decoders_for` | The venue's trades are missed: with no entry-point match they only surface when a known solver logs inside them, and intent decoding then excludes the trader | -| Extend what Hindsight knows about a venue | That venue's module in `venues/` — never anywhere else | Decoding degrades silently | -| Decode an intent settler (CoW-style) | A `TradeDecoder` in `intents/`, listed in `intents::decoders_for` ahead of the netting fallback | The settler's trades decode by net flow, losing exact amounts and (for contract owners) the venue | -| Attribute a new venue (owner / appData tag / fee wallet / integrator tag) | The matching address-book map (`[venue_owners]` / `[venue_appdata]` / `[venue_fees]` / `[venue_integrators]`); a provider's integrator tag also needs `SolverKnowledge::integrator` | The venue's trades are attributed to the underlying router or settler, not the venue | -| Add a new decode method | A `TradeDecoder` (a `netting`/`calldata` toolkit function behind it), listed for the entities that use it | Transactions the existing decoders cannot read stay undecoded | +| Track a new solver | One line in the address book's `[solvers]` section. No code — its trades match and net like any other | Trades sent directly to the solver's router never match; trades a known venue routed through it decode, but the solver is recorded as "unknown" | +| Make a solver's trades declared (trusted) instead of netted | A `SolverDecoder` impl in `solvers/` with `declared`, one row in `solvers::IMPLEMENTATIONS` | The solver's trades stay netted: marked, excluded from the report by default, and missing `min_amount_out` / `declared_quote` / `quote_timestamp` | +| Skip a solver's non-swap orders | An `Err(Veto)` from its `declared`, off a log its own module reads | Those orders decode as trades that never happened, with absurd rates | +| Add a venue | A `[venues.]` section in the address book — its entry points. No code | The venue's trades still decode when a known solver's frame or log is inside, but the venue label falls back to the raw entry address | +| Attribute a new venue (owner / appData tag / fee wallet / integrator tag) | The matching address-book map (`[venue_owners]` / `[venue_appdata]` / `[venue_fees]` / `[venue_integrators]`) | The venue's trades are attributed to the underlying router or settler, not the venue | +| Read a tag from a *new* solver's own data | A `venue_fingerprint` on its `SolverDecoder`, returning the `VenueTag` variant its data carries | The tag is never read, so venues sharing that solver's router fall back to its label | | Reject decodes that are not real trades (an NFT purchase's payment leg, a mis-paired wrap) | A check in `veto.rs` | Records that are not trades enter the comparison | -| Support a new chain | A `registry/.toml` address book, an entry in `registry::BUILTIN_CHAINS`, plus decoders for its venues and `SolverKnowledge` for its solvers that have none yet | The chain has no built-in book and must be passed via `--registry` | +| Support a new chain | A `registry/.toml` address book, an entry in `registry::BUILTIN_CHAINS` | The chain has no built-in book and must be passed via `--registry` | ### Re-solve monitor (`src/resolve/`) @@ -270,11 +234,11 @@ algorithm. ### The address book (`src/decoder/registry/.toml`) -All chain- and protocol-specific data — solver routers, venue entry points and fee collectors, -batch settlers, infrastructure contracts, USD-anchor stablecoins, display labels — lives in a -per-chain TOML loaded by `Registry`. One book is embedded at compile time per chain — ethereum, -base, unichain, arbitrum, bsc, polygon, robinhood — and `--chain ` picks one. Pass -`--registry ` to extend or replace a book without recompiling. +All chain- and protocol-specific data — solver routers, venue entry points, batch settlers, +infrastructure contracts, USD-anchor stablecoins, display labels — lives in a per-chain TOML +loaded by `Registry`. One book is embedded at compile time per chain — ethereum, +base, unichain, arbitrum, bsc, polygon, robinhood — and `--chain ` picks one. Pass `--registry ` +to extend or replace a book without recompiling. The books are not uniform, because the chains are not: CoW does not settle on Unichain and LiFi is not deployed there, so that book has no batch settlers, no LiFi solver, and no CoW-appData or @@ -296,6 +260,9 @@ RPC_URL=... ALLIUM_API_KEY=... ALLIUM_QUERY_ID=... \ # Live monitor (requires a Tycho feed) RPC_URL=... TYCHO_URL=... cargo run -p hindsight --release -- monitor --metrics-port 9898 + +# Report from a monitor run, declared records only (--include-netted adds the marked tier) +cargo run -p hindsight --release -- report --comparisons-dir ./comparisons ``` -The RPC endpoint must support `debug_traceTransaction`. +The RPC endpoint must support `debug_traceBlockByNumber`. diff --git a/tools/hindsight/src/decoder/attribution.rs b/tools/hindsight/src/decoder/attribution.rs new file mode 100644 index 000000000..456c937fc --- /dev/null +++ b/tools/hindsight/src/decoder/attribution.rs @@ -0,0 +1,495 @@ +//! Attribution: which solver settled a decoded trade, and which venue owns its order flow. +//! +//! Attribution runs after decoding and only labels the record — every function here reads. The +//! fee a `[venue_fees]` match finds is reported ([`venue_fee`]) and applied by the caller, which +//! is the one place that knows which decoder produced the amounts. +//! +//! The solver label comes from the first evidence tier that answers, most- to least-trusted +//! (see `AttributionSource`). The venue label is normally the contract the trader entered +//! through (`tx.to`); some venues own the order flow without being that contract and are +//! recognized from registry-driven fingerprints (owner, `appData` tag, fee wallet, integrator +//! tag). + +use std::collections::HashSet; + +use alloy::{ + primitives::{Address, U256}, + rpc::types::trace::geth::CallFrame, +}; +use serde::Serialize; + +use crate::decoder::{ + registry::Registry, + solvers::VenueTag, + trace, + transfer_ledger::{SettledSwap, TransferLedger}, +}; + +/// The evidence tier that produced a record's solver label, most- to least-trusted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AttributionSource { + /// The entry point (`tx.to`) is itself a known solver router: the trade settled there. + EntryPoint, + /// A known solver router was called inside the trace (venue-wrapped entries). + TraceMatch, + /// No known router anywhere: best guess is the external call that moved the most native + /// value (an unknown router's address). + LargestCall, + /// Even the guess was indeterminate (e.g. a token→token trace where no call moves value). + /// The record is labeled with its entry point — typically the venue's name — flagging it + /// for registry expansion. + Fallback, +} + +/// A solver label and the evidence tier it came from. +pub(crate) struct Attribution { + pub solver: String, + pub source: AttributionSource, + /// The router address the label came from, for callers that need the address book's entry for + /// it (the venue fingerprint read). `None` on the fallback tier, which named no router. + pub address: Option
, +} + +/// Attribute the solver that settled a matched transaction. +/// +/// Every tier reads the trace or the address book, ending at the entry-point label as the honest +/// "don't know". A venue's own claim about which solver it routed to is not consulted: the router +/// that settled the trade is in the trace, which is the harder fact. +pub(crate) fn solver( + root: &CallFrame, + entry_point: Address, + sender: Address, + registry: &Registry, +) -> Attribution { + if registry.is_solver(entry_point) { + return Attribution { + solver: registry.label(entry_point), + source: AttributionSource::EntryPoint, + address: Some(entry_point), + }; + } + if let Some(found) = trace::find_solver_frame(root, registry).and_then(|frame| frame.to) { + return Attribution { + solver: registry.label(found), + source: AttributionSource::TraceMatch, + address: Some(found), + }; + } + if let Some(guess) = trace::largest_external_call(root, entry_point, sender, registry) { + return Attribution { + solver: registry.label(guess), + source: AttributionSource::LargestCall, + address: Some(guess), + }; + } + Attribution { + solver: registry.label(entry_point), + source: AttributionSource::Fallback, + address: None, + } +} + +/// The order-flow venue for a decoded flow, when a fingerprint matches — overriding the +/// entry-point label. Every fingerprint is registry-driven; nothing here knows about a specific +/// venue or provider. +/// +/// Four fingerprints, tried in order: owning trader (`[venue_owners]`), an order's `appData` hash +/// (`[venue_appdata]`), fee wallet (`[venue_fees]`), a provider's integrator tag +/// (`[venue_integrators]`). The last two arrive as one `VenueTag`, read from the settling solver's +/// own data by `SolverDecoder::venue_fingerprint`; which map a tag is looked up in follows from +/// its variant, so this function still names no solver and no venue. +/// +/// A fee-wallet match is one of the four, so `fee` is passed in rather than looked up here: the +/// caller reads it once with [`venue_fee`] and also applies its correction, which is a separate +/// job from labelling (see [`VenueFee`]). +pub(crate) fn venue( + registry: &Registry, + flow: &SettledSwap, + tag: Option<&VenueTag>, + fee: Option<&VenueFee>, +) -> Option { + if let Some(venue) = registry.venue_for_owner(flow.tracked) { + return Some(venue.to_string()); + } + if let Some(VenueTag::AppData(hash)) = tag { + if let Some(venue) = registry.venue_for_appdata(*hash) { + return Some(venue.to_string()); + } + } + if let Some(fee) = fee { + return Some(fee.venue.clone()); + } + if let Some(VenueTag::Integrator(name)) = tag { + return registry + .venue_for_integrator(name) + .map(str::to_string); + } + None +} + +/// The cut a `[venue_fees]` wallet took out of one trade: which venue owns the wallet, which side +/// of the swap the cut came from, and how much. +/// +/// Deliberately not applied here. Fynd quotes the swap alone, so the recorded amounts have to be +/// put back on the swap's own basis — but whether they already are depends on which decoder +/// produced them, which only the caller knows. Applying it inside the venue label search made the +/// correction depend on which fingerprint won a race that has nothing to do with fees. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VenueFee { + /// The venue whose wallet was paid — also the venue label a fee-wallet match produces. + pub venue: String, + pub side: FeeSide, + pub amount: U256, +} + +/// Which side of the swap a venue took its fee from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FeeSide { + /// Skimmed off the input before the swap, so the swap saw less than the trader authorized. + Input, + /// Taken out of the output after the swap, so the trader kept less than the swap produced. + Output, +} + +/// The cut a venue's fee wallet took of this trade. `None` when no `[venue_fees]` wallet received +/// a non-zero amount of either swap token. +/// +/// Both sides are checked because venues split on this: Phantom and Robinhood take the buy token, +/// while Coinbase's Base App skims the sell token before routing. The output side is tried first — +/// a wallet that received both tokens is being paid its cut in the token the user bought. The +/// wallets are checked in address order, so two venues' wallets both taking a cut of one trade +/// resolve to the same venue on every run. +pub(crate) fn venue_fee( + registry: &Registry, + ledger: &TransferLedger, + token_in: Address, + token_out: Address, +) -> Option { + for (wallet, venue) in registry.venue_fees() { + let received = ledger.received_by(&HashSet::from([*wallet])); + let non_zero = |token: &Address| { + received + .get(token) + .copied() + .filter(|amount| !amount.is_zero()) + }; + if let Some(amount) = non_zero(&token_out) { + return Some(VenueFee { venue: venue.clone(), side: FeeSide::Output, amount }); + } + if let Some(amount) = non_zero(&token_in) { + return Some(VenueFee { venue: venue.clone(), side: FeeSide::Input, amount }); + } + } + None +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{address, b256, B256}; + use tycho_simulation::tycho_common::models::Chain; + + use super::*; + use crate::decoder::test_utils::{addr, frame, make_transfer_log, swap, PERMIT2}; + + #[test] + fn test_venue_wrapped_entry_reads_the_trace() { + // A venue-wrapped entry: the router that settled the trade is inside the trace, and that + // is what the record is labelled with — no venue's own claim is consulted. + let registry = Registry::ethereum(); + let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); + let mut root = frame("CALL", addr(1), addr(2), 0); + root.calls = vec![frame("CALL", addr(2), oneinch, 1000)]; + + let attribution = solver(&root, addr(2), addr(1), ®istry); + assert_eq!(attribution.solver, "1inch"); + assert_eq!(attribution.source, AttributionSource::TraceMatch); + } + + #[test] + fn test_direct_swap_entry_point() { + let registry = Registry::ethereum(); + let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); + let root = frame("CALL", addr(1), oneinch, 0); + + let attribution = solver(&root, oneinch, addr(1), ®istry); + assert_eq!(attribution.solver, "1inch"); + assert_eq!(attribution.source, AttributionSource::EntryPoint); + } + + #[test] + fn test_relay_internal_solver() { + // Mirrors the real Relay tx: the client router calls 0x's AllowanceHolder. + // root(relay) -> [ relay (self-call), 0x AllowanceHolder (the solver) ] + let registry = Registry::ethereum(); + let sender = addr(1); + let relay = address!("0xf5042e6ffac5a625d4e7848e0b01373d8eb9e222"); + let zerox = address!("0x0000000000001ff3684f28c67538d4d072c22734"); + + let mut root = frame("CALL", sender, relay, 0); + root.calls = vec![frame("CALL", relay, relay, 0), frame("CALL", relay, zerox, 1000)]; + + let attribution = solver(&root, relay, sender, ®istry); + assert_eq!(attribution.solver, "0x"); + assert_eq!(attribution.source, AttributionSource::TraceMatch); + } + + #[test] + fn test_relay_tycho_router() { + // Real tx 0x8b461c…: Relay ApprovalProxy -> Relay router -> Tycho router. + // The settling solver is Tycho even though it sits two levels deep. + let registry = Registry::ethereum(); + let sender = addr(1); + let relay_proxy = address!("0xccc88a9d1b4ed6b0eaba998850414b24f1c315be"); + let relay_router = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); + let tycho = address!("0x1f8db310f32d48b6180ff902ec60c586128cef47"); + + let mut router_call = frame("CALL", relay_proxy, relay_router, 0); + router_call.calls = vec![frame("CALL", relay_router, tycho, 0)]; + let mut root = frame("CALL", sender, relay_proxy, 0); + root.calls = vec![router_call]; + + let attribution = solver(&root, relay_proxy, sender, ®istry); + assert_eq!(attribution.solver, "tycho"); + assert_eq!(attribution.source, AttributionSource::TraceMatch); + } + + #[test] + fn test_unknown_solver_largest_external_call() { + // No known solver in the trace: pick the largest external call, + // skipping the client self-call and the refund back to the sender. + let registry = Registry::ethereum(); + let sender = addr(1); + let client = addr(2); + let unknown_router = addr(50); + + let mut root = frame("CALL", sender, client, 0); + root.calls = vec![ + frame("CALL", client, client, 0), // self-call, skipped + frame("CALL", client, sender, 9000), // refund to sender, skipped + frame("CALL", client, addr(51), 10), // small external call + frame("CALL", client, unknown_router, 5000), // largest external call + ]; + + let attribution = solver(&root, client, sender, ®istry); + assert_eq!(attribution.solver, unknown_router.to_string()); + assert_eq!(attribution.source, AttributionSource::LargestCall); + } + + #[test] + fn test_attribution_zero_value_fallback() { + // Unknown solver, token->token swap: every child call moves zero value, so the guess + // would degenerate to the first child (the Permit2 token pull). The record is labeled + // with its entry point instead, marked as a fallback. + let registry = Registry::ethereum(); + let sender = addr(1); + let client = addr(2); + + let mut root = frame("CALL", sender, client, 0); + root.calls = vec![ + frame("CALL", client, PERMIT2, 0), // token pull + frame("CALL", client, addr(50), 0), // unknown solver, zero value + ]; + + let attribution = solver(&root, client, sender, ®istry); + assert_eq!(attribution.solver, client.to_string()); + assert_eq!(attribution.source, AttributionSource::Fallback); + } + + #[test] + fn test_attribution_wrapped_native_frames() { + // ETH-input swap through an unknown router: the highest-value direct call is the + // WETH.deposit() wrapping the input. Infrastructure, not a solver — the guess must + // fall through to the real router call. + let registry = Registry::ethereum(); + let sender = addr(1); + let client = addr(2); + let unknown = addr(50); + + let mut root = frame("CALL", sender, client, 0); + root.calls = vec![ + frame("CALL", client, registry.wrapped_native(), 9000), // wrap, skipped + frame("CALL", client, unknown, 100), + ]; + + let attribution = solver(&root, client, sender, ®istry); + assert_eq!(attribution.solver, unknown.to_string()); + assert_eq!(attribution.source, AttributionSource::LargestCall); + } + + #[test] + fn test_attribution_permit2_frames() { + // Even when Permit2 is the highest-value direct call, it is infrastructure, not a solver. + let registry = Registry::ethereum(); + let sender = addr(1); + let client = addr(2); + let unknown = addr(50); + + let mut root = frame("CALL", sender, client, 0); + root.calls = + vec![frame("CALL", client, PERMIT2, 9000), frame("CALL", client, unknown, 100)]; + + let attribution = solver(&root, client, sender, ®istry); + assert_eq!(attribution.solver, unknown.to_string()); + assert_eq!(attribution.source, AttributionSource::LargestCall); + } + + #[test] + fn test_attributes_owner_to_venue() { + // A CoW-settled kpk trade nets to the Safe that owns the order; the venue is that Safe. + let registry = Registry::ethereum(); + let kpk_safe = address!("0x4f2083f5fbede34c2714affb3105539775f7fe64"); + let flow = SettledSwap { tracked: kpk_safe, ..swap(addr(10), 1, addr(11), 2) }; + assert_eq!(venue(®istry, &flow, None, None).as_deref(), Some("kpk")); + } + + #[test] + fn test_unknown_owner_is_not_a_venue() { + let registry = Registry::ethereum(); + let flow = SettledSwap { tracked: addr(9), ..swap(addr(10), 1, addr(11), 2) }; + assert_eq!(venue(®istry, &flow, None, None), None); + } + + #[test] + fn test_appdata_tag_attributes_venue() { + // A CoW order carrying DefiLlama's appData hash is attributed to LlamaSwap; an unregistered + // hash is not. + let registry = Registry::ethereum(); + let defillama = b256!("0xf249b3db926aa5b5a1b18f3fec86b9cc99b9a8a99ad7e8034242d2838ae97422"); + let flow = SettledSwap { tracked: addr(1), ..swap(addr(10), 1, addr(11), 2) }; + assert_eq!( + venue(®istry, &flow, Some(&VenueTag::AppData(defillama)), None).as_deref(), + Some("llamaswap") + ); + assert_eq!(venue(®istry, &flow, Some(&VenueTag::AppData(B256::ZERO)), None), None); + } + + #[test] + fn test_fee_wallet_attributes_the_venue_and_reports_the_cut() { + // A 0x-routed Phantom swap: the buy-token fee reaches Phantom's wallet, which both names + // the venue and is the cut the caller has to correct out. + let registry = Registry::ethereum(); + let phantom = address!("0x2cffed5d56eb6a17662756ca0fdf350e732c9818"); + let user = addr(1); + let pool = addr(50); + let token_in = addr(10); + let token_out = addr(11); + let logs = vec![ + make_transfer_log(token_in, user, pool, U256::from(1000)), + make_transfer_log(token_out, pool, user, U256::from(9915)), + make_transfer_log(token_out, pool, phantom, U256::from(85)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let flow = SettledSwap { tracked: user, ..swap(token_in, 1000, token_out, 9915) }; + + let fee = venue_fee(®istry, &ledger, flow.token_in, flow.token_out).unwrap(); + assert_eq!(fee.side, FeeSide::Output); + assert_eq!(fee.amount, U256::from(85)); + assert_eq!(venue(®istry, &flow, None, Some(&fee)).as_deref(), Some("phantom")); + } + + #[test] + fn test_fee_is_reported_even_when_another_fingerprint_names_the_venue() { + // A kpk trade (owner match) that also paid a Phantom fee leg. The owner wins the label, + // and the cut is still reported: the correction must not depend on which fingerprint won. + let registry = Registry::ethereum(); + let kpk_safe = address!("0x4f2083f5fbede34c2714affb3105539775f7fe64"); + let phantom = address!("0x2cffed5d56eb6a17662756ca0fdf350e732c9818"); + let token_in = addr(10); + let token_out = addr(11); + let logs = vec![ + make_transfer_log(token_out, addr(50), kpk_safe, U256::from(9915)), + make_transfer_log(token_out, addr(50), phantom, U256::from(85)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let flow = SettledSwap { tracked: kpk_safe, ..swap(token_in, 1000, token_out, 9915) }; + + let fee = venue_fee(®istry, &ledger, flow.token_in, flow.token_out).unwrap(); + assert_eq!(fee.amount, U256::from(85)); + assert_eq!(venue(®istry, &flow, None, Some(&fee)).as_deref(), Some("kpk")); + } + + #[test] + fn test_integrator_tag_attributes_venue() { + // A provider integrator tag maps to its venue, case-insensitively; an unknown tag does + // not. + let registry = Registry::ethereum(); + let flow = SettledSwap { tracked: addr(1), ..swap(addr(10), 1, addr(11), 2) }; + assert_eq!( + venue(®istry, &flow, Some(&VenueTag::Integrator("Infinex".into())), None).as_deref(), + Some("infinex") + ); + assert_eq!( + venue(®istry, &flow, Some(&VenueTag::Integrator("somedapp".into())), None), + None + ); + } + + #[test] + fn test_fee_wallet_paid_in_the_sell_token_is_an_input_fee() { + // A LiFi-routed Coinbase Base App swap: the 0.95% cut is skimmed off the sell token, so + // the cut is reported on the input side for the caller to subtract. + let registry = Registry::builtin(Chain::Bsc).unwrap(); + let coinbase = address!("0x5aafc1f252d544f744d17a4e734afd6efc47ede4"); + let user = addr(1); + let pool = addr(50); + let token_in = addr(10); + let token_out = addr(11); + let logs = vec![ + make_transfer_log(token_in, user, coinbase, U256::from(95)), + make_transfer_log(token_in, user, pool, U256::from(9905)), + make_transfer_log(token_out, pool, user, U256::from(2000)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let flow = SettledSwap { tracked: user, ..swap(token_in, 10000, token_out, 2000) }; + + let fee = venue_fee(®istry, &ledger, flow.token_in, flow.token_out).unwrap(); + assert_eq!(fee.side, FeeSide::Input); + assert_eq!(fee.amount, U256::from(95)); + assert_eq!( + venue(®istry, &flow, Some(&VenueTag::Integrator("base-app".into())), Some(&fee)) + .as_deref(), + Some("coinbase") + ); + } + + #[test] + fn test_fee_wallet_taking_both_tokens_is_read_as_an_output_fee() { + // A wallet that received both swap tokens is being paid its cut in the token the user + // bought; the sell-token leg is the swap's own routing, not a second fee. + let registry = Registry::ethereum(); + let phantom = address!("0x2cffed5d56eb6a17662756ca0fdf350e732c9818"); + let user = addr(1); + let token_in = addr(10); + let token_out = addr(11); + let logs = vec![ + make_transfer_log(token_in, user, phantom, U256::from(7)), + make_transfer_log(token_out, addr(50), phantom, U256::from(85)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let flow = SettledSwap { tracked: user, ..swap(token_in, 1000, token_out, 9915) }; + + let fee = venue_fee(®istry, &ledger, flow.token_in, flow.token_out).unwrap(); + assert_eq!(fee.side, FeeSide::Output); + assert_eq!(fee.amount, U256::from(85)); + assert_eq!(venue(®istry, &flow, None, Some(&fee)).as_deref(), Some("phantom")); + } + + #[test] + fn test_no_fee_transfer_is_not_a_venue() { + // Dust to the fee wallet in a token other than the output is not this trade's fee. + let registry = Registry::ethereum(); + let user = addr(1); + let pool = addr(50); + let token_in = addr(10); + let token_out = addr(11); + let logs = vec![ + make_transfer_log(token_in, user, pool, U256::from(1000)), + make_transfer_log(token_out, pool, user, U256::from(2000)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let flow = SettledSwap { tracked: user, ..swap(token_in, 1000, token_out, 2000) }; + assert_eq!(venue_fee(®istry, &ledger, flow.token_in, flow.token_out), None); + assert_eq!(venue(®istry, &flow, None, None), None); + } +} diff --git a/tools/hindsight/src/decoder/declared.rs b/tools/hindsight/src/decoder/declared.rs new file mode 100644 index 000000000..f0cc87ddc --- /dev/null +++ b/tools/hindsight/src/decoder/declared.rs @@ -0,0 +1,503 @@ +//! The declared decode: the trade as the settling solver's own data states it. +//! +//! This is the primary decode for every matched transaction, regardless of venue. A solver either +//! states its trade in its own logs — amounts included, nothing left to recover — or carries the +//! terms in its calldata, in which case the settled `amount_out` is recovered as the gross amount +//! the declared output recipient received. That one field is the only thing calldata never carries. +//! +//! Neither amount is fee-adjusted here: the frame's own figures are recorded as read. A fee paid +//! to a wallet in `[venue_fees]` is corrected afterwards by `super::attribution::venue`, which is +//! the only place that knows a fee wallet. No venue knowledge is needed to decode. + +use alloy::{ + primitives::{Address, U256}, + rpc::types::{trace::geth::CallFrame, Log}, +}; + +use crate::decoder::{ + registry::Registry, + solvers::{self, DeclaredSwap}, + trace, + transfer_ledger::{SettledSwap, TransferLedger}, + veto::Veto, + DecodeSource, +}; + +/// Decode a transaction from the settling solver's own declaration: the decoder label recorded on +/// the trade, the settled swap, and the terms the solver declared alongside it. +/// +/// Only the settling solver is asked — the outermost known solver in the trace — so a record's +/// amounts and its solver label always come from the same solver. A transaction that merely +/// touches another solver's router somewhere is not read by that solver. +/// +/// A transaction that entered a solver router several times independently is declined here: its +/// legs are separate swaps, so one frame's calldata states a fragment of what was traded, not the +/// trade. Netting still runs — it reads the trader's own balances rather than one frame, so it +/// either finds a single net swap or declines. That keeps the honest reading and drops the +/// fragment. +/// +/// `Ok(None)` means no solver was read at all — no known solver frame, several independent frames, +/// or the settling solver's own data carried nothing it could parse — so the caller falls back to +/// netting. +/// +/// `Err(veto)` means the transaction is dropped, and netting is not tried. Either the solver said +/// it is not a swap, or it named an output that the transfers do not show: once a solver has told +/// us the trade, netting would answer a different question, so its answer is not substituted. +pub(crate) fn declared_flow( + root: &CallFrame, + registry: &Registry, + logs: &[Log], + transfer_ledger: &TransferLedger, + sender: Address, +) -> Result, Veto> { + let frames = trace::solver_frames(root, registry); + if frames.len() > 1 { + crate::telemetry::record_several_legs(); + return Ok(None); + } + let Some(solver_frame) = frames.first().copied() else { return Ok(None) }; + let Some(solver) = solver_frame + .to + .and_then(|address| registry.solver(address)) + else { + return Ok(None); + }; + let Some(mut declared) = solver + .decoder + .declared(&solver_frame.input, logs)? + else { + return Ok(None); + }; + // Calldata fixes one side of the trade and only bounds the other, so whichever side it left + // bounded is recovered from the transfers. An event states both and nothing is recovered, + // which is what the label records. + let label = if declared.amount_out.is_some() && declared.amount_in.is_some() { + DecodeSource::DeclaredFromLogs + } else { + DecodeSource::DeclaredFromCalldata + }; + let amount_out = match declared.amount_out { + Some(amount_out) => amount_out, + None => recover_output(&declared, transfer_ledger, sender).ok_or(Veto::OutputNotFound)?, + }; + let amount_in = match declared.amount_in { + Some(amount_in) => amount_in, + None => recover_input(&declared, transfer_ledger, sender).ok_or(Veto::InputNotFound)?, + }; + // A quote is self-reported decoration: integrators sometimes fill it in a different token or + // decimal basis, which would fabricate a huge slippage. One that far from the settled amount + // is dropped, and the trade — whose tokens and amounts are ABI-decoded facts — is kept. + if let Some(quoted) = declared.declared_quote { + if !solvers::plausible_quote(quoted, amount_out) { + declared.declared_quote = None; + declared.timestamp = None; + } + } + let settled = SettledSwap { + tracked: declared.tracked.unwrap_or(sender), + token_in: declared.token_in, + amount_in, + token_out: declared.token_out, + amount_out, + }; + Ok(Some((label, settled, declared))) +} + +/// Recover a settled `amount_out` the solver's data did not state: the gross amount the declared +/// recipient received (a solver that declares none delivers to the caller, so the transaction +/// sender is the fallback anchor). +/// +/// `None` when the recipient received none of the token, or less than the floor the same calldata +/// enforces — a settled trade cleared its floor by construction, so a smaller receipt means the +/// query read the wrong legs of a multi-order transaction. The caller drops the transaction on +/// either, rather than letting netting answer instead. +fn recover_output( + declared: &DeclaredSwap, + transfer_ledger: &TransferLedger, + sender: Address, +) -> Option { + let recipient = declared + .output_recipient + .unwrap_or(sender); + let amount_out = transfer_ledger.received_by_address(recipient, declared.token_out); + if amount_out.is_zero() || + amount_out < + declared + .min_amount_out + .unwrap_or(U256::ZERO) + { + return None; + } + Some(amount_out) +} + +/// Recover an `amount_in` the solver's calldata did not state: an exact-output call fixes the +/// output and only bounds the input, so what was spent is the payer's net payment of `token_in` — +/// gross paid less any refund. A native-ETH input is overfunded up to the ceiling and the +/// remainder is swept back, which is the refund this subtracts. +/// +/// The payer is the transaction sender: a Permit2 pull names the trader as the sender of the +/// transfer, and native ETH arrives as the transaction's own value. +/// +/// `None` when the sender paid none of the token — the swap was funded from the router's own +/// balance, which says nothing about what this trade spent — or more than the ceiling the same +/// calldata enforces, which means the query read the wrong legs of a multi-order transaction. The +/// caller drops the transaction on either, rather than letting netting answer instead. +fn recover_input( + declared: &DeclaredSwap, + transfer_ledger: &TransferLedger, + sender: Address, +) -> Option { + let paid = transfer_ledger.sent_by_address(sender, declared.token_in); + let refunded = transfer_ledger.received_by_address(sender, declared.token_in); + let amount_in = paid.saturating_sub(refunded); + if amount_in.is_zero() || + amount_in > + declared + .max_amount_in + .unwrap_or(U256::MAX) + { + return None; + } + Some(amount_in) +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{address, U256}; + + use super::*; + use crate::decoder::test_utils::{addr, frame, make_transfer_log}; + + /// Fly's own router — same address on every chain (`docs.fly.trade`). + const FLY: Address = address!("0x20f6ee51340adeed01a59b0e65cb3703f3dc860c"); + /// 0x's v4 exchange proxy — a registered solver with no declared read. + const ZEROX: Address = address!("0xdef1c0ded9bec7f1a1670819833240f027b25eff"); + /// Relay's own router — in the live fixture this is both the entry point and the + /// declared output recipient Fly's calldata carries (Relay receives and forwards). + const ROUTER: Address = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); + + /// The real Fly calldata used by `solvers::fly`'s fixture tests: USDT in, native out, + /// `amount_in` 19,694,643, `min_amount_out` 10,217,898,321,149,381, declared quote + /// 10,321,109,415,302,405. + fn fly_input() -> Vec { + let text = include_str!("solvers/fixtures/fly_input.txt").trim(); + alloy::hex::decode(text.strip_prefix("0x").unwrap_or(text)).unwrap() + } + + const TOKEN_IN: Address = address!("0xfde4c96c8593536e31f229ea8f37b2ada2699bb2"); + const AMOUNT_IN: u64 = 19_694_643; + const MIN_AMOUNT_OUT: u128 = 10_217_898_321_149_381; + const QUOTED_AMOUNT_OUT: u128 = 10_321_109_415_302_405; + + /// A root frame: `sender -> router -> solver`, the solver frame carrying `input`. + fn root_with_solver_frame(sender: Address, router: Address, solver: Address) -> CallFrame { + let mut solver_call = frame("CALL", router, solver, 0); + solver_call.input = fly_input().into(); + let mut root = frame("CALL", sender, router, 0); + root.calls = vec![solver_call]; + root + } + + #[test] + fn test_several_legs_are_declined_to_netting() { + // Two independent entries into a solver router, the shape an arbitrage contract routing + // several legs produces. One frame's calldata states one leg, not the trade, so no frame + // is read and the caller falls back to netting — `Ok(None)`, not a veto, because netting + // reads the trader's balances rather than a frame and may still find a single swap. + let registry = Registry::ethereum(); + let sender = addr(1); + let mut first = frame("CALL", ROUTER, FLY, 0); + first.input = fly_input().into(); + let mut second = frame("CALL", ROUTER, FLY, 0); + second.input = fly_input().into(); + let mut root = frame("CALL", sender, ROUTER, 0); + root.calls = vec![first, second]; + let ledger = TransferLedger::from_transaction(&[], &[]); + + assert!(declared_flow(&root, ®istry, &[], &ledger, sender) + .expect("several legs decline rather than veto") + .is_none()); + } + + #[test] + fn test_a_solver_inside_another_solvers_frame_is_not_a_second_leg() { + // 0x reached from inside Fly's own frame is a step in Fly's route, not a leg of its own, + // so this is still a one-leg transaction and Fly's calldata is read. + let registry = Registry::ethereum(); + let sender = addr(1); + let mut root = root_with_solver_frame(sender, ROUTER, FLY); + root.calls[0].calls = vec![frame("CALL", FLY, ZEROX, 0)]; + let logs = vec![make_transfer_log(TOKEN_IN, sender, ROUTER, U256::from(AMOUNT_IN))]; + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; + let ledger = TransferLedger::from_transaction(&logs, &native); + + let (_, flow, _) = declared_flow(&root, ®istry, &[], &ledger, sender) + .unwrap() + .expect("one leg, so Fly's calldata is read"); + assert_eq!(flow.amount_in, U256::from(AMOUNT_IN)); + } + + /// `LiFi`'s Diamond, and a bridge-shaped log emitted by it. + const LIFI: Address = address!("0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae"); + + fn bridge_log(emitter: Address) -> Log { + use alloy::sol_types::SolEvent; + + let primitive = alloy::primitives::Log::new_unchecked( + emitter, + vec![solvers::lifi::LiFiTransferStarted::SIGNATURE_HASH], + alloy::primitives::Bytes::default(), + ); + Log { inner: primitive, ..Default::default() } + } + + #[test] + fn test_bridge_order_vetoes_the_whole_transaction() { + // LiFi is the settling solver and its log says the order bridged out: the veto reaches + // the caller, which drops the transaction instead of letting netting pair the input with + // the dust refund. + let registry = Registry::ethereum(); + let sender = addr(1); + let root = root_with_solver_frame(sender, ROUTER, LIFI); + let ledger = TransferLedger::from_transaction(&[], &[]); + + let logs = vec![bridge_log(LIFI)]; + assert_eq!( + declared_flow(&root, ®istry, &logs, &ledger, sender).err(), + Some(Veto::BridgeOrder) + ); + } + + #[test] + fn test_bridge_log_from_another_solvers_transaction_does_not_veto() { + // The same bridge-shaped log, but Fly settled this transaction. Only the settling solver + // is asked, so LiFi's veto cannot reach a trade that is not LiFi's. + let registry = Registry::ethereum(); + let sender = addr(1); + let root = root_with_solver_frame(sender, ROUTER, FLY); + let logs = vec![bridge_log(LIFI)]; + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; + let ledger = TransferLedger::from_transaction(&[], &native); + + let (_, flow, _) = declared_flow(&root, ®istry, &logs, &ledger, sender) + .unwrap() + .unwrap(); + assert_eq!(flow.amount_in, U256::from(AMOUNT_IN)); + } + + #[test] + fn test_decode_recovers_output_from_recipient_receipt() { + // The router — the declared recipient — receives native ETH above the floor; the + // sender pays the input token directly (sender-funded). + let registry = Registry::ethereum(); + let sender = addr(1); + let root = root_with_solver_frame(sender, ROUTER, FLY); + let logs = vec![make_transfer_log(TOKEN_IN, sender, ROUTER, U256::from(AMOUNT_IN))]; + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; + let ledger = TransferLedger::from_transaction(&logs, &native); + + let (_, flow, declared) = declared_flow(&root, ®istry, &[], &ledger, sender) + .unwrap() + .unwrap(); + assert_eq!(flow.tracked, sender); + assert_eq!(flow.token_in, TOKEN_IN); + assert_eq!(flow.token_out, Address::ZERO); + assert_eq!(flow.amount_in, U256::from(AMOUNT_IN)); + assert_eq!(flow.amount_out, U256::from(MIN_AMOUNT_OUT + 1_000)); + assert_eq!(declared.min_amount_out, Some(U256::from(MIN_AMOUNT_OUT))); + } + + #[test] + fn test_decode_below_floor_declines() { + // The recipient's receipt sits under the intent's on-chain floor: a successful trade + // clears its floor by construction, so this means the query mis-attributed. + let registry = Registry::ethereum(); + let sender = addr(1); + let root = root_with_solver_frame(sender, ROUTER, FLY); + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT - 1))]; + let ledger = TransferLedger::from_transaction(&[], &native); + + assert_eq!( + declared_flow(&root, ®istry, &[], &ledger, sender).err(), + Some(Veto::OutputNotFound) + ); + } + + #[test] + fn test_decode_no_recipient_receipt_drops_the_transaction() { + // Fly's calldata names the token and the address it is paid to, and that address received + // none of it. Netting is not asked instead — the solver already told us the trade. + let registry = Registry::ethereum(); + let sender = addr(1); + let root = root_with_solver_frame(sender, ROUTER, FLY); + let ledger = TransferLedger::from_transaction(&[], &[]); + + assert_eq!( + declared_flow(&root, ®istry, &[], &ledger, sender).err(), + Some(Veto::OutputNotFound) + ); + } + + /// An exact-output read: the output is fixed at 500, the input bounded at 1,000. + fn exact_out() -> DeclaredSwap { + DeclaredSwap::from_calldata_exact_out( + TOKEN_IN, + Address::ZERO, + U256::from(500u64), + U256::from(1_000u64), + ) + } + + #[test] + fn test_recover_input_from_the_senders_payment() { + let sender = addr(1); + let logs = vec![make_transfer_log(TOKEN_IN, sender, addr(50), U256::from(900u64))]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + + assert_eq!(recover_input(&exact_out(), &ledger, sender), Some(U256::from(900u64))); + } + + #[test] + fn test_recover_input_subtracts_the_swept_remainder() { + // The router is funded up to the ceiling and sweeps back what the swap did not need. + let sender = addr(1); + let router = addr(50); + let logs = vec![ + make_transfer_log(TOKEN_IN, sender, router, U256::from(1_000u64)), + make_transfer_log(TOKEN_IN, router, sender, U256::from(150u64)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + + assert_eq!(recover_input(&exact_out(), &ledger, sender), Some(U256::from(850u64))); + } + + #[test] + fn test_recover_input_without_a_payment_declines() { + // The swap was funded from the router's own balance, which says nothing about what this + // trade spent. + let ledger = TransferLedger::from_transaction(&[], &[]); + + assert_eq!(recover_input(&exact_out(), &ledger, addr(1)), None); + } + + #[test] + fn test_recover_input_above_the_ceiling_declines() { + // A settled swap stayed under its ceiling by construction, so a larger figure means the + // query read the wrong legs of a multi-order transaction. + let sender = addr(1); + let logs = vec![make_transfer_log(TOKEN_IN, sender, addr(50), U256::from(1_500u64))]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + + assert_eq!(recover_input(&exact_out(), &ledger, sender), None); + } + + #[test] + fn test_decode_no_solver_frame_declines() { + let registry = Registry::ethereum(); + let sender = addr(1); + let root = frame("CALL", sender, ROUTER, 0); + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; + let ledger = TransferLedger::from_transaction(&[], &native); + + assert!(declared_flow(&root, ®istry, &[], &ledger, sender) + .unwrap() + .is_none()); + } + + #[test] + fn test_decode_solver_without_a_declared_read_declines() { + // 0x's v4 proxy is a registered solver (matches `find_solver_frame`) but has no + // `declared` implementation: the calldata path has nothing to recover, so it falls + // through to netting. + let registry = Registry::ethereum(); + let sender = addr(1); + let root = root_with_solver_frame(sender, ROUTER, ZEROX); + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; + let ledger = TransferLedger::from_transaction(&[], &native); + + assert!(declared_flow(&root, ®istry, &[], &ledger, sender) + .unwrap() + .is_none()); + } + + #[test] + fn test_decode_implausible_quote_drops_the_quote_not_the_trade() { + // A recovered output more than 2x the declared quote reads as a unit mismatch, so the + // quote goes. The tokens and amounts are ABI-decoded facts, so the trade stays. + let registry = Registry::ethereum(); + let sender = addr(1); + let root = root_with_solver_frame(sender, ROUTER, FLY); + let implausible = U256::from(QUOTED_AMOUNT_OUT) * U256::from(3u64); + let native = vec![(addr(50), ROUTER, implausible)]; + let ledger = TransferLedger::from_transaction(&[], &native); + + let (_, flow, declared) = declared_flow(&root, ®istry, &[], &ledger, sender) + .unwrap() + .unwrap(); + assert_eq!(flow.amount_out, implausible); + assert_eq!(declared.declared_quote, None); + assert_eq!(declared.min_amount_out, Some(U256::from(MIN_AMOUNT_OUT))); + } + + #[test] + fn test_decode_third_party_funded_rebalance() { + // Someone other than the sender net-sends the input token: a solver-initiated rebalance + // still decodes from the calldata, with the intent's own amounts. + let registry = Registry::ethereum(); + let sender = addr(1); + let funder = addr(99); + let root = root_with_solver_frame(sender, ROUTER, FLY); + let logs = vec![make_transfer_log(TOKEN_IN, funder, ROUTER, U256::from(AMOUNT_IN))]; + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; + let ledger = TransferLedger::from_transaction(&logs, &native); + + let (_, flow, _) = declared_flow(&root, ®istry, &[], &ledger, sender) + .unwrap() + .unwrap(); + assert_eq!(flow.tracked, sender); + assert_eq!(flow.amount_in, U256::from(AMOUNT_IN)); + } + + #[test] + fn test_decode_ignores_an_input_side_fee_leg() { + // An input-side fee leg to an address no address book knows: `amount_in` stays the + // frame's own figure. Only a `[venue_fees]` wallet is corrected, and that happens in + // attribution, not here. + let registry = Registry::ethereum(); + let sender = addr(1); + let root = root_with_solver_frame(sender, ROUTER, FLY); + let logs = vec![ + make_transfer_log(TOKEN_IN, sender, ROUTER, U256::from(AMOUNT_IN)), + make_transfer_log(TOKEN_IN, ROUTER, addr(99), U256::from(40)), + ]; + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; + let ledger = TransferLedger::from_transaction(&logs, &native); + + let (_, flow, _) = declared_flow(&root, ®istry, &[], &ledger, sender) + .unwrap() + .unwrap(); + assert_eq!(flow.amount_in, U256::from(AMOUNT_IN)); + } + + #[test] + fn test_decode_needs_no_venue() { + // A direct transaction: the root frame is itself the solver frame, and nothing about the + // decode consults a venue. + let registry = Registry::ethereum(); + let sender = addr(1); + let mut root = frame("CALL", sender, FLY, 0); + root.input = fly_input().into(); + let logs = vec![make_transfer_log(TOKEN_IN, sender, FLY, U256::from(AMOUNT_IN))]; + // The recipient is whatever the calldata declares — here Relay's router, even though the + // transaction never went near a venue. + let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; + let ledger = TransferLedger::from_transaction(&logs, &native); + + let (_, flow, _) = declared_flow(&root, ®istry, &[], &ledger, sender) + .unwrap() + .unwrap(); + assert_eq!(flow.amount_in, U256::from(AMOUNT_IN)); + assert_eq!(flow.amount_out, U256::from(MIN_AMOUNT_OUT + 1_000)); + } +} diff --git a/tools/hindsight/src/decoder/decode.rs b/tools/hindsight/src/decoder/decode.rs deleted file mode 100644 index cd0aeb82e..000000000 --- a/tools/hindsight/src/decoder/decode.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! Decoding a matched transaction into a trader's flow. -//! -//! One decoder handles one matched transaction. Which decoder runs is chosen by the matched -//! entity (`decoders_for`): a direct sender, an intent order, or a specific venue. Each entity -//! maps to an ordered list of `TradeDecoder`s tried in turn — the first that returns a flow -//! wins, so a later one is the fallback for what the earlier ones cannot decode. That is where an -//! entity picks how its swaps are read, in the order it prefers. -//! -//! What a decoder reads is open — the value movements, the calldata, the event logs, a -//! combination, or a source not needed yet; all of it arrives in the `DecodeContext`, and a -//! decoder takes only what it needs. `netting` is the shared engine that exists today; a method -//! bespoke to one protocol lives in that protocol's module. Everything around decoding — matching, -//! vetoes, attribution, gas, quotes — stays in the orchestrator. - -use std::collections::HashMap; - -use alloy::{ - network::AnyTransactionReceipt, - primitives::{Address, U256}, - providers::Provider, - rpc::types::trace::geth::CallFrame, -}; -use async_trait::async_trait; - -use crate::decoder::{ - intents, - netting_decoders::SenderNetting, - registry::{Registry, VenueAddresses}, - transfer_ledger::{NetSwap, TransferLedger}, - venues, -}; - -/// Decode one matched, traced transaction into the trader's flow, or `None` when this decoder -/// cannot. Async because a decoder may need RPC lookups beyond the transaction (e.g. checking an -/// address for contract code). -#[async_trait] -pub(crate) trait TradeDecoder: Send + Sync { - /// Label recorded on the trades this decoder produced, so the JSONL records say which decoder - /// carried each trade (deliberately not a metric label). - fn name(&self) -> &'static str; - - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option; -} - -/// Whose flow a matched transaction carries — the axis that selects the decoders. -#[derive(Debug, Clone, Copy)] -pub(crate) enum TraderRole<'a> { - /// The transaction sender (a direct solver swap). - Sender, - /// An intent fill: the sender is a solver or batch settler acting for the swapper. - Intent, - /// A venue the sender entered through, named by its address-book section. - Venue(&'a str), -} - -impl<'a> TraderRole<'a> { - /// Classify the role from the entry point. Assumes the transaction already matched (see - /// `matching`): an entry point that is neither a venue nor otherwise known can only have - /// matched via a solver log, which is a solver-initiated intent fill. - fn classify(entry_point: Address, registry: &'a Registry) -> Self { - if let Some(name) = registry.venue_name(entry_point) { - return TraderRole::Venue(name); - } - // Batch settlers (e.g. CoW) are entered by a solver, not the trader, so the real swap is - // the swapper's net flow — decoded like a solver-initiated intent fill. - if registry.is_batch_settler(entry_point) { - return TraderRole::Intent; - } - if registry.is_known(entry_point) { - return TraderRole::Sender; - } - TraderRole::Intent - } -} - -/// The decoders tried for a role, in order — the first to return a flow wins. This is the one -/// place the entity → decoder mapping lives: an entity lists its decoders, in the order it wants -/// them tried. -fn decoders_for(role: TraderRole<'_>) -> Vec>> { - match role { - TraderRole::Sender => vec![Box::new(SenderNetting)], - TraderRole::Intent => intents::decoders_for(), - TraderRole::Venue(name) => venues::decoders_for(name), - } -} - -/// Decode a matched transaction: pick the decoders for its role and try them in order. Returns -/// the winning decoder's name with the flow. -pub(crate) async fn recover( - ctx: &mut DecodeContext<'_, P>, -) -> Option<(&'static str, TraderFlow)> { - let role = TraderRole::classify(ctx.entry_point, ctx.registry); - if let TraderRole::Venue(name) = role { - let registry = ctx.registry; - ctx.venue = registry.venue(name); - } - try_decoders(decoders_for(role), ctx).await -} - -/// Try each decoder in order; the first flow wins and the rest are not consulted. -async fn try_decoders( - decoders: Vec>>, - ctx: &mut DecodeContext<'_, P>, -) -> Option<(&'static str, TraderFlow)> { - for decoder in decoders { - if let Some(flow) = decoder.decode(ctx).await { - return Some((decoder.name(), flow)); - } - } - None -} - -/// Everything a decoder may read from one matched transaction. -/// -/// Every kind of evidence is gathered up front, for every matched transaction, regardless of -/// which decoder wins: the receipt and its logs, the root calldata, and the flattened transfer -/// ledger all arrive here. A decoder that starts needing another input extends this struct. -pub(crate) struct DecodeContext<'a, P> { - /// RPC access, for decoders that must look beyond the transaction. - pub provider: &'a P, - pub registry: &'a Registry, - /// Cross-block contract-code cache, owned by the decoder. - pub code_cache: &'a mut HashMap, - /// The matched transaction's receipt (sender, logs). - pub receipt: &'a AnyTransactionReceipt, - /// The contract the transaction entered through (`tx.to`). - pub entry_point: Address, - /// The transaction's flattened value movements. - pub transfer_ledger: &'a TransferLedger, - /// The transaction's root calldata. Venues declare their solver in it; some solvers embed - /// their quote. - pub input: &'a [u8], - /// The transaction's root trace frame. A decoder that must find the settling solver's own - /// call (its calldata, its declared output recipient) walks this itself rather than netting - /// the ledger — e.g. a packed calldata layout (Fly) only decodes inside its own frame. - pub root: &'a CallFrame, - /// The matched venue's address-book section (entry points, fee collectors, solver aliases), - /// set when the transaction entered through a venue so venue decoders never look themselves - /// up by name. `None` for direct and intent transactions. - pub venue: Option<&'a VenueAddresses>, -} - -/// Which part of the transaction's gas counts as the settled route's cost. Decided by the -/// decoder — only it knows who sent the transaction and what wraps the route. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GasScope { - /// The trader sent the transaction: all of its gas is the route's cost. - WholeTransaction, - /// The trade runs inside a venue's contract: only the solver call's trace frame counts, - /// keeping the venue's own overhead out of the comparison. - SolverFrame, - /// Someone other than the trader paid the gas (intent fills, solver rebalances): none of it - /// is charged. - NotCharged, -} - -/// The trader's side of a matched transaction: the swap, plus the corrections that make it -/// comparable (venue fees backed out, gas scope). -pub(crate) struct TraderFlow { - /// The address whose net flow the swap was read from. - pub tracked: Address, - pub swap: NetSwap, - /// Venue fee taken from the input token, already backed out of `swap.amount_in`. - pub venue_fee_in: Option, - /// Venue fee taken from the output token, already added back into `swap.amount_out`. - pub venue_fee_out: Option, - /// Solver label asserted by the decoder itself (e.g. `MetaMask` declares its solver in - /// calldata), overriding trace-based attribution. - pub solver_override: Option, - /// How the settled route's gas is charged against the settled output. - pub gas_scope: GasScope, -} - -impl TraderFlow { - pub(crate) fn without_fees(tracked: Address, swap: NetSwap) -> Self { - Self { - tracked, - swap, - venue_fee_in: None, - venue_fee_out: None, - solver_override: None, - gas_scope: GasScope::NotCharged, - } - } - - /// Record `fee` as an output-token venue fee and gross it back into `swap.amount_out`, so the - /// settled output stays comparable to Fynd's gross re-solve. A no-op when an output fee was - /// already accounted, so a second matching fee leg cannot double-count. - pub(crate) fn gross_output_fee(&mut self, fee: U256) { - if self.venue_fee_out.is_some() { - return; - } - self.venue_fee_out = Some(fee); - self.swap.amount_out = self.swap.amount_out.saturating_add(fee); - } - - /// Record `fee` as an input-token venue fee and net it out of `swap.amount_in`, so the settled - /// input is what actually reached the pools rather than the user's gross spend. A no-op when an - /// input fee was already accounted (a venue decoder ran first and knows better). - /// - /// Without this, a venue skimming its fee off the input makes the settled trade look bigger - /// than it was, and Fynd — re-solved on that inflated size — appears to beat it. - pub(crate) fn net_input_fee(&mut self, fee: U256) { - if self.venue_fee_in.is_some() { - return; - } - self.venue_fee_in = Some(fee); - self.swap.amount_in = self.swap.amount_in.saturating_sub(fee); - } -} - -#[cfg(test)] -mod tests { - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - - use alloy::{providers::RootProvider, rpc::client::RpcClient, transports::mock::Asserter}; - - use super::*; - use crate::decoder::test_utils::{addr, frame, receipt, swap, tx_hash}; - - /// Always declines. - struct Declines; - - #[async_trait] - impl TradeDecoder

for Declines { - fn name(&self) -> &'static str { - "declines" - } - - async fn decode(&self, _ctx: &mut DecodeContext<'_, P>) -> Option { - None - } - } - - /// Always decodes a fixed flow. - struct Wins; - - #[async_trait] - impl TradeDecoder

for Wins { - fn name(&self) -> &'static str { - "wins" - } - - async fn decode(&self, _ctx: &mut DecodeContext<'_, P>) -> Option { - Some(TraderFlow::without_fees(addr(1), swap(addr(10), 1, addr(11), 2))) - } - } - - /// Declines, counting how often it was consulted. - struct CountsCalls(Arc); - - #[async_trait] - impl TradeDecoder

for CountsCalls { - fn name(&self) -> &'static str { - "counts" - } - - async fn decode(&self, _ctx: &mut DecodeContext<'_, P>) -> Option { - self.0.fetch_add(1, Ordering::SeqCst); - None - } - } - - async fn try_with( - decoders: Vec>>, - ) -> Option<(&'static str, TraderFlow)> { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let registry = Registry::ethereum(); - let mut code_cache = HashMap::new(); - let receipt = receipt(tx_hash(1), addr(1), Some(addr(2)), vec![]); - let transfer_ledger = TransferLedger::from_transaction(&[], &[]); - let root = frame("CALL", addr(1), addr(2), 0); - let mut ctx = DecodeContext { - provider: &provider, - registry: ®istry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point: addr(2), - transfer_ledger: &transfer_ledger, - input: &[], - root: &root, - venue: None, - }; - try_decoders(decoders, &mut ctx).await - } - - #[tokio::test] - async fn test_first_decoder_declines() { - let (name, flow) = try_with(vec![Box::new(Declines), Box::new(Wins)]) - .await - .unwrap(); - assert_eq!(name, "wins"); - assert_eq!(flow.tracked, addr(1)); - } - - #[tokio::test] - async fn test_first_decoder_succeeds() { - let calls = Arc::new(AtomicUsize::new(0)); - let (name, _) = try_with(vec![Box::new(Wins), Box::new(CountsCalls(Arc::clone(&calls)))]) - .await - .unwrap(); - assert_eq!(name, "wins"); - assert_eq!(calls.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn test_no_decoder_answers() { - assert!(try_with(vec![Box::new(Declines)]) - .await - .is_none()); - } -} diff --git a/tools/hindsight/src/decoder/intents/cow.rs b/tools/hindsight/src/decoder/intents/cow.rs deleted file mode 100644 index 3aa494107..000000000 --- a/tools/hindsight/src/decoder/intents/cow.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! `CoW` Protocol settlement decoding. -//! -//! `CoW` settles signed orders in a batch: `tx.to` is the settlement contract and `tx.from` is the -//! solver, so the trade is an order owner's — read here from the `GPv2` `Trade` event the -//! settlement emits per order. The event gives the exact executed amounts and the owner directly, -//! which is more precise than netting the settlement's transfers and names the owner for client -//! attribution (`kpk`). -//! -//! One trade is produced per transaction, so only single-order settlements are decoded; a batch -//! settling several orders is declined to the generic intent netting (which nets one swapper's -//! flow). `CoW`'s fee is taken from the sell token and backed out of the input so a re-solve -//! compares like-for-like — modern `CoW` records a zero on-chain fee (it is priced into the order). - -use alloy::{ - primitives::{address, Address, B256}, - providers::Provider, - sol, - sol_types::{SolCall, SolEvent}, -}; -use async_trait::async_trait; - -use crate::decoder::{ - decode::{DecodeContext, GasScope, TradeDecoder, TraderFlow}, - transfer_ledger::{to_primitive_log, NetSwap}, -}; - -sol! { - /// `GPv2` per-order settlement event. - event Trade( - address indexed owner, - address sellToken, - address buyToken, - uint256 sellAmount, - uint256 buyAmount, - uint256 feeAmount, - bytes orderUid - ); - - /// `GPv2Settlement.settle`, decoded only for the per-order `appData` hash — the frontend tag - /// (`appCode`) the event does not carry. The other fields are named to match the ABI so the - /// decode lines up; only `trades[].appData` is read. - struct SettleTrade { - uint256 sellTokenIndex; - uint256 buyTokenIndex; - address receiver; - uint256 sellAmount; - uint256 buyAmount; - uint32 validTo; - bytes32 appData; - uint256 feeAmount; - uint256 flags; - uint256 executedAmount; - bytes signature; - } - struct SettleInteraction { - address target; - uint256 value; - bytes callData; - } - function settle( - address[] tokens, - uint256[] clearingPrices, - SettleTrade[] trades, - SettleInteraction[][3] interactions - ); -} - -/// The settled order's `appData` hash, read from the `settle` calldata. `None` unless the batch -/// settles exactly one order — the same single-order rule `CowSettlement` applies, since a -/// multi-order batch has no single frontend to attribute. -pub(crate) fn order_app_data(input: &[u8]) -> Option { - let call = settleCall::abi_decode(input).ok()?; - let [trade] = call.trades.as_slice() else { - return None; - }; - Some(trade.appData) -} - -/// `CoW`'s sentinel for native ETH in buy orders, mapped to the zero address like every other flow. -const COW_NATIVE_ETH: Address = address!("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); - -/// `CoW`'s settlement decoder, reading the `GPv2` `Trade` event. -pub(crate) struct CowSettlement; - -#[async_trait] -impl TradeDecoder

for CowSettlement { - fn name(&self) -> &'static str { - "cow-trade" - } - - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - let mut trades = ctx.receipt.logs().iter().filter(|log| { - ctx.registry - .is_batch_settler(log.address()) && - log.topics().first() == Some(&Trade::SIGNATURE_HASH) - }); - let first = trades.next()?; - // One trade per transaction: a multi-order batch is left to the generic intent netting. - if trades.next().is_some() { - return None; - } - let trade = Trade::decode_log(&to_primitive_log(first)).ok()?; - - // CoW's fee is taken from the sell token, so the amount that actually reached the market is - // the executed sell minus the fee. - let amount_in = trade - .sellAmount - .saturating_sub(trade.feeAmount); - let fee = (!trade.feeAmount.is_zero()).then_some(trade.feeAmount); - Some(TraderFlow { - tracked: trade.owner, - swap: NetSwap { - token_in: normalize_native(trade.sellToken), - amount_in, - token_out: normalize_native(trade.buyToken), - amount_out: trade.buyAmount, - }, - venue_fee_in: fee, - venue_fee_out: None, - solver_override: None, - // The solver pays settlement gas and recoups it in the order price, not the trader. - gas_scope: GasScope::NotCharged, - }) - } -} - -fn normalize_native(token: Address) -> Address { - if token == COW_NATIVE_ETH { - Address::ZERO - } else { - token - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use alloy::{ - primitives::{address, b256, Bytes, U256}, - providers::RootProvider, - rpc::{client::RpcClient, types::Log}, - sol_types::SolCall, - transports::mock::Asserter, - }; - - use super::*; - use crate::decoder::{ - registry::Registry, - test_utils::{addr, frame, receipt, swap, tx_hash}, - transfer_ledger::TransferLedger, - }; - - /// The Ethereum `CoW` settlement contract (a registered batch settler). - const COW_SETTLEMENT: Address = address!("0x9008d19f58aabd9ed0d60971565aa8510560ab41"); - - fn trade_log( - settler: Address, - owner: Address, - sell_token: Address, - buy_token: Address, - sell_amount: u64, - buy_amount: u64, - fee_amount: u64, - ) -> Log { - let event = Trade { - owner, - sellToken: sell_token, - buyToken: buy_token, - sellAmount: U256::from(sell_amount), - buyAmount: U256::from(buy_amount), - feeAmount: U256::from(fee_amount), - orderUid: Bytes::new(), - }; - let data = event.encode_log_data(); - let primitive = alloy::primitives::Log::new_unchecked( - settler, - data.topics().to_vec(), - data.data.clone(), - ); - Log { inner: primitive, ..Default::default() } - } - - async fn decode(logs: Vec) -> Option { - let registry = Registry::ethereum(); - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); - let receipt = receipt(tx_hash(1), addr(2), Some(COW_SETTLEMENT), logs); - let transfer_ledger = TransferLedger::from_transaction(&[], &[]); - let root = frame("CALL", addr(2), COW_SETTLEMENT, 0); - let mut ctx = DecodeContext { - provider: &provider, - registry: ®istry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point: COW_SETTLEMENT, - transfer_ledger: &transfer_ledger, - input: &[], - root: &root, - venue: None, - }; - CowSettlement.decode(&mut ctx).await - } - - #[tokio::test] - async fn test_single_order_reads_the_trade_event() { - let owner = addr(100); - let sell = addr(10); - let buy = addr(11); - // Fee is taken from the sell token: 10 of the 1000 sold is the fee, 990 reached the market. - let flow = decode(vec![trade_log(COW_SETTLEMENT, owner, sell, buy, 1000, 2000, 10)]) - .await - .unwrap(); - assert_eq!(flow.tracked, owner); - assert_eq!(flow.swap, swap(sell, 990, buy, 2000)); - assert_eq!(flow.venue_fee_in, Some(U256::from(10))); - assert_eq!(flow.gas_scope, GasScope::NotCharged); - } - - #[tokio::test] - async fn test_native_eth_sentinel_normalized() { - let flow = decode(vec![trade_log( - COW_SETTLEMENT, - addr(100), - addr(10), - COW_NATIVE_ETH, - 1000, - 5, - 0, - )]) - .await - .unwrap(); - assert_eq!(flow.swap.token_out, Address::ZERO); - assert_eq!(flow.venue_fee_in, None); - } - - #[tokio::test] - async fn test_multi_order_batch_declined() { - // Two orders in one settlement: one trade per transaction, so this is left to intent - // netting. - let logs = vec![ - trade_log(COW_SETTLEMENT, addr(100), addr(10), addr(11), 1000, 2000, 0), - trade_log(COW_SETTLEMENT, addr(101), addr(11), addr(10), 2000, 1000, 0), - ]; - assert!(decode(logs).await.is_none()); - } - - #[tokio::test] - async fn test_no_trade_event_declined() { - // A non-CoW intent fill (no Trade event) is declined so intent netting runs instead. - assert!(decode(vec![]).await.is_none()); - } - - fn settle_trade(app_data: B256) -> SettleTrade { - SettleTrade { - sellTokenIndex: U256::ZERO, - buyTokenIndex: U256::ZERO, - receiver: Address::ZERO, - sellAmount: U256::ZERO, - buyAmount: U256::ZERO, - validTo: 0, - appData: app_data, - feeAmount: U256::ZERO, - flags: U256::ZERO, - executedAmount: U256::ZERO, - signature: Bytes::new(), - } - } - - fn settle_calldata(trades: Vec) -> Vec { - settleCall { - tokens: vec![], - clearingPrices: vec![], - trades, - interactions: Default::default(), - } - .abi_encode() - } - - #[test] - fn test_single_order_reads_app_data() { - let app = b256!("0xf249b3db926aa5b5a1b18f3fec86b9cc99b9a8a99ad7e8034242d2838ae97422"); - assert_eq!(order_app_data(&settle_calldata(vec![settle_trade(app)])), Some(app)); - } - - #[test] - fn test_multi_order_batch_has_no_single_app_data() { - let trades = vec![settle_trade(B256::ZERO), settle_trade(B256::ZERO)]; - assert!(order_app_data(&settle_calldata(trades)).is_none()); - } - - #[test] - fn test_non_settle_calldata_has_no_app_data() { - assert!(order_app_data(&[0u8; 4]).is_none()); - } -} diff --git a/tools/hindsight/src/decoder/intents/mod.rs b/tools/hindsight/src/decoder/intents/mod.rs deleted file mode 100644 index 7e78a4d02..000000000 --- a/tools/hindsight/src/decoder/intents/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Intent-role decoders: transactions a solver sends on the trader's behalf. -//! -//! Unlike a venue (entered by the trader, so the sender is the trader), an intent fill or batch -//! settlement is sent by a solver — the trader only signed an order. So these decoders find the -//! real trader inside the transaction rather than reading the sender's flow. This mirrors -//! `venues/`: one place lists the Intent role's decoders, tried in order. - -pub(crate) mod cow; -pub(crate) mod netting; - -use alloy::{ - primitives::{Address, B256}, - providers::Provider, -}; - -use crate::decoder::{decode::TradeDecoder, registry::Registry}; - -/// The decoders tried for the Intent role, first flow wins: a source with a rich signal (`CoW`'s -/// `Trade` event) is tried before the generic net-flow finder that works for any intent fill. -pub(crate) fn decoders_for() -> Vec>> { - vec![Box::new(cow::CowSettlement), Box::new(netting::IntentNetting)] -} - -/// The order-flow tag a batch settlement carries for venue attribution: `CoW`'s per-order -/// `appData` hash, read from the settle calldata. `None` for entries that are not batch settlers -/// and for multi-order batches. Mirrors `solvers::integrator` — the orchestrator asks for a tag -/// without knowing which intent protocol produced it. -pub(crate) fn venue_tag(registry: &Registry, entry_point: Address, input: &[u8]) -> Option { - registry - .is_batch_settler(entry_point) - .then(|| cow::order_app_data(input)) - .flatten() -} diff --git a/tools/hindsight/src/decoder/intents/netting.rs b/tools/hindsight/src/decoder/intents/netting.rs deleted file mode 100644 index 09948aad8..000000000 --- a/tools/hindsight/src/decoder/intents/netting.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! Generic intent decoding: the fallback for the Intent role. -//! -//! Covers transactions where the sender is not the trader — solver-initiated intent fills -//! (`UniswapX`, 1inch limit orders) and batch settlements — by finding the order swapper's net -//! flow. `IntentNetting` is the decoder; `find_intent_trade` does the finding. A source with a -//! richer signal (see `super::cow`) is tried ahead of this. - -use std::collections::HashMap; - -use alloy::{primitives::Address, providers::Provider}; -use async_trait::async_trait; -use tracing::warn; - -use crate::decoder::{ - decode::{DecodeContext, TradeDecoder, TraderFlow}, - registry::Registry, - transfer_ledger::{NetSwap, TransferLedger}, -}; - -/// Solver-initiated intent fills and batch settlements: the sender acts on the swapper's behalf, so -/// the real swap is the swapper's net flow. -pub(crate) struct IntentNetting; - -#[async_trait] -impl TradeDecoder

for IntentNetting { - fn name(&self) -> &'static str { - "intent-netting" - } - - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - find_intent_trade( - ctx.provider, - ctx.transfer_ledger, - &[ctx.entry_point, ctx.receipt.from], - ctx.registry, - ctx.code_cache, - ) - .await - } -} - -/// Find the order swapper's trade in a solver-initiated intent fill. -/// -/// The transaction sender is the solver, not the swapper, so we look for the -/// externally-owned account whose net flow is a clean two-token swap. Contracts -/// never qualify (checked via `eth_getCode`): pools and routers net the inverse -/// swap or leftover dust, and recording an intermediary's dust as the trade -/// produces absurd "swaps" (seen live: WETH → 2.4e-7 AAVE). Known registry -/// contracts and the excluded addresses (solver, entry point) are skipped too. -/// A fill with no clean-net EOA is declined rather than guessed. -/// -/// v0 limitations (tracked for a decode/attribution rework): -/// - **One swapper per transaction.** The first clean-net EOA wins, so a batch that settles several -/// retail orders in one tx contributes a single decoded trade; the rest surface as "Allium only" -/// gaps and batch volume is under-counted. -/// - **No settlement-tied tiebreak.** When several non-excluded EOAs each net to a clean two-token -/// swap, the winner is just the first in `intent_candidates`' address-ordered iteration, so a -/// decode can attribute the wrong account's flow. -/// - **Smart-wallet swappers are declined.** A swapper behind contract code (account abstraction, -/// EIP-7702 delegation) is indistinguishable from a pool here, so its fills are dropped. -pub(crate) async fn find_intent_trade( - provider: &P, - transfer_ledger: &TransferLedger, - exclude: &[Address], - registry: &Registry, - code_cache: &mut HashMap, -) -> Option { - for (candidate, trade) in intent_candidates(transfer_ledger, exclude, registry) { - if !is_contract(provider, candidate, code_cache).await { - return Some(TraderFlow::without_fees(candidate, trade)); - } - } - None -} - -/// Addresses with a clean two-token net swap, excluding the zero address, the -/// excluded addresses, and known registry contracts. Ordered by address for -/// deterministic selection. -fn intent_candidates( - transfer_ledger: &TransferLedger, - exclude: &[Address], - registry: &Registry, -) -> Vec<(Address, NetSwap)> { - let mut candidates = transfer_ledger.participants(); - candidates.remove(&Address::ZERO); - candidates.retain(|address| !exclude.contains(address) && !registry.is_known(*address)); - - let mut swaps = Vec::new(); - for candidate in candidates { - if let Some(trade) = transfer_ledger.net_swap(candidate) { - swaps.push((candidate, trade)); - } - } - swaps -} - -/// Whether an address has contract code, cached across blocks. On RPC failure -/// the address is treated as a contract so it is not mistaken for an EOA -/// swapper. -/// -/// v0 limitation: an EIP-7702-delegated account carries code, so a 7702 swapper EOA is classified -/// as a contract and dropped. 7702 is not yet widely used, so this is accepted for now. -async fn is_contract( - provider: &P, - address: Address, - cache: &mut HashMap, -) -> bool { - if let Some(is_contract) = cache.get(&address) { - return *is_contract; - } - let is_contract = match provider.get_code_at(address).await { - Ok(code) => !code.is_empty(), - Err(error) => { - warn!(%address, %error, "failed to fetch code; treating as contract"); - true - } - }; - cache.insert(address, is_contract); - is_contract -} - -#[cfg(test)] -mod tests { - use alloy::{ - primitives::{Bytes, U256}, - providers::RootProvider, - rpc::client::RpcClient, - transports::mock::Asserter, - }; - - use super::*; - use crate::decoder::test_utils::{addr, make_transfer_log, swap}; - - fn mocked_provider(asserter: &Asserter) -> RootProvider { - RootProvider::new(RpcClient::mocked(asserter.clone())) - } - - /// The swapper/pool inverse-swap fixture: swapper sells `token_a` for `token_b`, pool nets the - /// inverse. - fn inverse_swap_ledger() -> TransferLedger { - let logs = vec![ - make_transfer_log(addr(10), addr(100), addr(101), U256::from(1000)), - make_transfer_log(addr(11), addr(101), addr(100), U256::from(2000)), - ]; - TransferLedger::from_transaction(&logs, &[]) - } - - #[tokio::test] - async fn test_find_intent_trade_eoa_candidate() { - let asserter = Asserter::new(); - // Candidates in address order: addr(100) first — an EOA (empty code). - asserter.push_success(&Bytes::default()); - let provider = mocked_provider(&asserter); - - let registry = Registry::ethereum(); - let mut cache = HashMap::new(); - let flow = find_intent_trade(&provider, &inverse_swap_ledger(), &[], ®istry, &mut cache) - .await - .unwrap(); - assert_eq!(flow.tracked, addr(100)); - assert_eq!(flow.swap, swap(addr(10), 1000, addr(11), 2000)); - } - - #[tokio::test] - async fn test_find_intent_trade_all_candidates_contracts() { - let asserter = Asserter::new(); - // Both candidates carry code: a routing intermediary and a pool. Guessing one would net - // residue dust as an absurd swap, so the fill must be declined. - asserter.push_success(&Bytes::from(vec![0xfe])); - asserter.push_success(&Bytes::from(vec![0xfe])); - let provider = mocked_provider(&asserter); - - let registry = Registry::ethereum(); - let mut cache = HashMap::new(); - let flow = - find_intent_trade(&provider, &inverse_swap_ledger(), &[], ®istry, &mut cache).await; - assert!(flow.is_none()); - } - - #[test] - fn test_intent_candidates_swap_sides() { - // Intent fill: the swapper sells token_a for token_b; the pool is the - // counterparty. The solver is excluded. - let registry = Registry::ethereum(); - let swapper = addr(100); - let pool = addr(101); - let solver = addr(102); - let token_a = addr(10); - let token_b = addr(11); - - let logs = vec![ - make_transfer_log(token_a, swapper, pool, U256::from(1000)), - make_transfer_log(token_b, pool, swapper, U256::from(2000)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - let found: HashMap = intent_candidates(&transfer_ledger, &[solver], ®istry) - .into_iter() - .collect(); - assert_eq!(found.len(), 2); - assert_eq!(found[&swapper], swap(token_a, 1000, token_b, 2000)); - // The pool nets the inverse swap; the EOA filter discards it later. - assert_eq!(found[&pool], swap(token_b, 2000, token_a, 1000)); - } - - #[test] - fn test_intent_candidates_excluded_and_known() { - let registry = Registry::ethereum(); - let swapper = addr(100); - let pool = addr(101); - let token_a = addr(10); - let token_b = addr(11); - - let logs = vec![ - make_transfer_log(token_a, swapper, pool, U256::from(1000)), - make_transfer_log(token_b, pool, swapper, U256::from(2000)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - // Excluding the swapper leaves only the pool. - let candidates = intent_candidates(&transfer_ledger, &[swapper], ®istry); - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].0, pool); - } -} diff --git a/tools/hindsight/src/decoder/matching.rs b/tools/hindsight/src/decoder/matching.rs deleted file mode 100644 index 5c980e3f8..000000000 --- a/tools/hindsight/src/decoder/matching.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Which transactions in a block are solver trades. -//! -//! `select` is the cheap, receipt-only filter the decoder runs on every transaction before -//! anything costs a trace. It answers only "is this a solver trade at all" — how the trade is -//! then decoded is the decoders' job (see `decode`). - -use alloy::{ - network::{AnyTransactionReceipt, ReceiptResponse}, - primitives::Address, -}; -use tracing::debug; - -use crate::decoder::{registry::Registry, solvers}; - -/// A transaction identified as a solver trade, ready to be traced and decoded. -pub(crate) struct MatchedSolverTrade<'a> { - pub receipt: &'a AnyTransactionReceipt, - /// The contract the transaction entered through (`tx.to`). - pub entry_point: Address, -} - -/// Match a receipt as a solver trade. -/// -/// A transaction qualifies two ways: its entry point (`tx.to`) is a known -/// venue or solver, or one of its logs was emitted by a known solver -/// (filler-initiated intent fills, where `tx.to` is a rotating filler). -/// Matched transactions whose logs mark a non-swap order shape are vetoed -/// here (see `solvers::solver_veto`), before they cost a trace. -pub(crate) fn select<'a>( - receipt: &'a AnyTransactionReceipt, - registry: &Registry, -) -> Option> { - let matched = match_entry(receipt, registry)?; - if let Some(veto) = solvers::solver_veto(matched.receipt.logs(), matched.entry_point, registry) - { - debug!( - tx = %matched.receipt.transaction_hash, - venue = %registry.label(matched.entry_point), - ?veto, - "matched transaction is not a same-chain swap; skipping" - ); - return None; - } - Some(matched) -} - -/// Match a receipt by its entry point or its solver logs. -fn match_entry<'a>( - receipt: &'a AnyTransactionReceipt, - registry: &Registry, -) -> Option> { - if !receipt.status() { - return None; - } - let entry_point = receipt.to?; - if registry.is_known(entry_point) { - return Some(MatchedSolverTrade { receipt, entry_point }); - } - let via_log = receipt - .logs() - .iter() - .any(|log| registry.is_solver(log.address())); - via_log.then_some(MatchedSolverTrade { receipt, entry_point }) -} diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index 3465710aa..f09087edb 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -1,30 +1,34 @@ //! Decode solver trades from on-chain data. //! //! Terminology — three tiers, two of which appear in every record: -//! - **venue** (`venues/`): the contract the user entered through (`tx.to`) — Relay, `MetaMask`. -//! Order-flow owners; they pick a solver and may take a fee. -//! - **solver** (`solvers/`): the router that computed and settled the route — `KyberSwap`, 1inch, -//! 0x. These are Fynd's competitors. Datasets recorded before run6 call this tier `aggregator` in -//! their column names; the two words mean the same thing. +//! - **venue**: the contract the user entered through (`tx.to`) — Relay, `MetaMask`. Order-flow +//! owners; they pick a solver and may take a fee. +//! - **solver** (`solvers/`, the only tier with code): the router that computed and settled the +//! route — `KyberSwap`, 1inch, 0x. These are Fynd's competitors. Datasets recorded before run6 +//! call this tier `aggregator` in their column names; the two words mean the same thing. //! - **liquidity venues**: the pools and makers a route executes against (Uniswap, Curve, //! prop-AMMs). Not modeled here; they only appear inside traces. //! -//! The pipeline is match → trace → decode → veto → record: `matching` filters a block down to -//! solver trades, `decode` recovers each trade's swap (picking the decoders for the matched -//! entity), `transfer_ledger` answers all value-flow questions, `veto` rejects shapes that are not -//! comparable trades, and `registry` is the address book behind matching. - -mod decode; -mod intents; -mod matching; -mod netting_decoders; +//! The pipeline is three steps, per block: +//! +//! 1. **Trace the whole block** — one `eth_getBlockReceipts` call and one +//! `debug_traceBlockByNumber` call. +//! 2. **Per transaction, decode the swap from the solver's side** — the declared decode reads the +//! settling solver frame's own calldata (`declared`), or `CoW`'s `Trade` log for batch +//! settlements; `netting` is the fallback, and its records are marked (`decode: "netted"`). A +//! transaction with no known solver frame, venue entry, batch settler, or solver log is skipped. +//! `veto` rejects shapes that are not comparable trades. +//! 3. **Attribute** — `attribution` names the solver and the venue on the record; `registry` is the +//! address book behind every lookup. + +mod attribution; +mod declared; +mod netting; mod registry; mod sandwich; mod solvers; mod trace; mod transfer_ledger; -mod venue_attribution; -pub(crate) mod venues; mod veto; #[cfg(test)] @@ -34,25 +38,79 @@ use std::collections::HashMap; use alloy::{ eips::BlockId, - network::AnyTransactionReceipt, + network::{AnyTransactionReceipt, ReceiptResponse}, primitives::{Address, TxHash, U256}, providers::Provider, rpc::types::trace::geth::CallFrame, }; use anyhow::Context; -use futures::stream::StreamExt; use tracing::{debug, warn}; -use crate::decoder::{ - decode::{recover, DecodeContext, GasScope, TraderFlow}, - matching::MatchedSolverTrade, - solvers::SwapIntent, - trace::{collect_native_transfers, fetch_trace, route_gas}, - transfer_ledger::TransferLedger, -}; pub(crate) use crate::decoder::{ - registry::Registry, sandwich::SandwichEvidence, solvers::attribution::AttributionSource, + attribution::AttributionSource, registry::Registry, sandwich::SandwichEvidence, }; +use crate::decoder::{ + solvers::DeclaredSwap, + trace::{collect_native_transfers, fetch_block_traces}, + transfer_ledger::{SettledSwap, TransferLedger}, +}; + +/// Which decoder recovered a record's settled amounts. +/// +/// The `serde` names are the JSONL column values, so a variant can be renamed without moving the +/// wire format or the Grafana queries that read it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub(crate) enum DecodeSource { + /// The settling solver's calldata stated the terms; `amount_out` came from the recipient's + /// receipt. + #[serde(rename = "solver-calldata")] + DeclaredFromCalldata, + /// The settling solver's own event stated both amounts; nothing was recovered. + #[serde(rename = "solver-logs")] + DeclaredFromLogs, + /// Balance netting, entered through a known venue's address. + #[serde(rename = "venue-netting")] + VenueNetting, + /// Balance netting on the transaction sender's own flow. + #[serde(rename = "sender-netting")] + SenderNetting, + /// Balance netting on a trader found inside a settlement the sender only relayed. + #[serde(rename = "intent-netting")] + IntentNetting, +} + +/// How far a record's amounts can be trusted — the decode tier, which follows from the decoder +/// that produced them and is never chosen separately. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DecodeTier { + /// Read from the settling solver's own data: the trusted tier, and the report's default scope. + Declared, + /// Recovered by balance netting, which can leave an unaccounted fee inside the amounts. The + /// report excludes these unless `--include-netted`. + Netted, +} + +impl DecodeSource { + /// The tier this decoder produces. Reading a solver's own data is declared; netting is not. + pub(crate) fn tier(self) -> DecodeTier { + match self { + Self::DeclaredFromCalldata | Self::DeclaredFromLogs => DecodeTier::Declared, + Self::VenueNetting | Self::SenderNetting | Self::IntentNetting => DecodeTier::Netted, + } + } +} + +impl DecodeTier { + /// The JSONL column value for this tier. The report reads historical files as plain strings, + /// so it compares against this rather than a literal of its own. + pub(crate) fn wire(self) -> &'static str { + match self { + Self::Declared => "declared", + Self::Netted => "netted", + } + } +} /// A decoded solver trade: what token went in, what came out. /// @@ -66,43 +124,32 @@ pub(crate) struct DecodedTrade { pub tx_index: u64, pub venue: String, pub solver: String, - /// The evidence tier the solver label came from (see `solvers::attribution`). Downstream + /// The evidence tier the solver label came from (see `attribution`). Downstream /// analysis weighs low-trust tiers (`largest_call`, fallback) differently — e.g. when judging /// an embedded quote. pub solver_source: AttributionSource, - /// Which decoder recovered this trade (see `decode`). Once several decoders can carry a - /// venue's trades this measures how often each one carries a trade the others could not. - pub decoder: &'static str, + /// Which decoder recovered this trade. Once several decoders can carry a venue's trades this + /// measures how often each one carries a trade the others could not. + pub decoder: DecodeSource, + /// The tier `decoder` produces, written to its own column so a reader need not know the + /// decoder-to-tier mapping. Always `decoder.tier()` — never chosen independently. + pub decode: DecodeTier, pub sender: Address, pub token_in: Address, pub token_out: Address, - /// Input amount that actually entered the swap — a venue fee taken from the input (see - /// `venue_fee_in`) is already subtracted, so a re-solve compares like-for-like. + /// Input amount that entered the swap. A fee paid to a `[venue_fees]` wallet out of the input + /// is subtracted (see `apply_venue_fee`), so a re-solve is quoted the amount that reached the + /// pools. pub amount_in: U256, - /// Gross swap output — a venue fee taken from the output (see `venue_fee_out`) is added - /// back, so the settled amount is the full swap proceeds, comparable to Fynd's gross output. + /// Gross swap output. A fee paid to a `[venue_fees]` wallet out of the output is added back + /// (see `apply_venue_fee`) unless the recorded figure already includes it, so the settled + /// amount is the full swap proceeds, comparable to Fynd's gross output. pub amount_out: U256, - /// Venue fee taken from the input token before swapping (e.g. Relay's fee), in `token_in` - /// units. `None` when no known fee collector took a cut. Recorded for transparency; it is - /// already excluded from `amount_in`. - #[serde(skip_serializing_if = "Option::is_none")] - pub venue_fee_in: Option, - /// Venue fee taken from the output token after swapping, in `token_out` units. `None` when - /// no known fee collector took a cut. Recorded for transparency; it is already added back into - /// `amount_out`. - #[serde(skip_serializing_if = "Option::is_none")] - pub venue_fee_out: Option, - /// Wei cost of the gas the trader paid for the settled route (`gas_used × - /// effective_gas_price`). For venue-wrapped entries (Relay, `MetaMask`) the venue's own - /// overhead is excluded — it is charged whichever router the venue picks, like the venue - /// fee. `None` when the trader did not pay the transaction's gas (intent fills, solver - /// rebalances) or the route's gas could not be isolated from the trace. - #[serde(skip_serializing_if = "Option::is_none")] - pub settled_gas: Option, /// The on-chain enforced floor declared in the settling solver frame's own calldata (see - /// `solvers::swap_intent` for the solvers that declare one). A settled trade cleared this by - /// construction; it is recorded so avoidance analysis has the same field on both settled and - /// reverted trades. `None` when no solver frame was found or its calldata did not parse. + /// `SolverDecoder::declared` for the solvers that declare one). A settled trade cleared + /// this by construction; it is recorded so avoidance analysis has the same field on both + /// settled and reverted trades. `None` when no solver frame was found or its calldata did + /// not parse. #[serde(skip_serializing_if = "Option::is_none")] pub min_amount_out: Option, /// The solver's own off-chain quote, when its calldata declares one (unit-checked against @@ -120,76 +167,83 @@ pub(crate) struct DecodedTrade { pub sandwich: Option, } -/// Log a disagreement between the calldata-recovered intent and the netted flow, on any of the -/// three terms they both claim. The ledger stays authoritative for what settled; two -/// independently-derived readings landing on different terms is diagnostic signal we would -/// otherwise lose, not a decode failure. Skipped for `relay-calldata`, whose flow already IS the -/// intent, so there is nothing independent to disagree with. -fn warn_on_intent_disagreement( - decoder: &str, - tx_hash: TxHash, - intent: Option<&SwapIntent>, - flow: &TraderFlow, +/// Put the recorded amounts back on the swap's own basis when a `[venue_fees]` wallet took a cut. +/// +/// Fynd quotes the swap alone, so a venue's cut has to come out of the comparison on whichever +/// side it was taken: +/// +/// - **Input side**: the wallet is paid out of the amount the trader authorized, so the pools saw +/// less than `amount_in` states. Subtracted, or Fynd is re-solved on more input than the swap had +/// and its larger output reads as savings. +/// - **Output side**: the trader's receipt is short of the gross output by the fee, while Fynd's +/// quote is gross. Added back, unless the recorded output already contains it — see +/// `output_already_gross`. +/// +/// Runs for every trade with a fee-wallet match, whichever fingerprint produced the venue label. +fn apply_venue_fee( + flow: &mut SettledSwap, + fee: &attribution::VenueFee, + declared: Option<&DeclaredSwap>, + transfer_ledger: &TransferLedger, + sender: Address, ) { - let Some(intent) = intent.filter(|_| decoder != "relay-calldata") else { - return; - }; - if intent.token_in == flow.swap.token_in && - intent.token_out == flow.swap.token_out && - intent.amount_in == flow.swap.amount_in - { - return; + match fee.side { + attribution::FeeSide::Input => { + flow.amount_in = flow + .amount_in + .saturating_sub(fee.amount); + } + attribution::FeeSide::Output => { + if output_already_gross(flow, fee, declared, transfer_ledger, sender) { + return; + } + flow.amount_out = flow + .amount_out + .saturating_add(fee.amount); + } } - warn!( - tx = %tx_hash, - intent_token_in = %intent.token_in, - intent_token_out = %intent.token_out, - intent_amount_in = %intent.amount_in, - flow_token_in = %flow.swap.token_in, - flow_token_out = %flow.swap.token_out, - flow_amount_in = %flow.swap.amount_in, - "calldata-recovered intent disagrees with the netted flow" - ); } -/// Copy the calldata-declared terms off a parsed intent, or all-`None` when no intent was -/// recovered. Split out of `decode_transaction` purely to keep it under the line limit. -/// The trader's swap terms, when the settling solver frame's own calldata declares them. +/// Whether the recorded `amount_out` already includes an output-side venue fee, in which case +/// adding it back would count the venue's cut twice. /// -/// Dispatched with the solver frame's input, not the root transaction's — a packed calldata layout -/// (Fly) uses offsets valid only in its own frame — and with the decoded flow's input amount as a -/// hint for scan-based extractors (`ParaSwap`). A declared quote that fails the unit-plausibility -/// check against `settled_amount_out` is dropped (quotes are self-reported); the ABI-decoded terms -/// stay either way. -fn solver_intent( - root: &CallFrame, - registry: &Registry, - solver: &str, - amount_in: U256, - settled_amount_out: U256, -) -> Option { - let frame = trace::find_solver_frame(root, registry)?; - let mut intent = solvers::swap_intent(solver, &frame.input, Some(amount_in))?; - if let Some(quoted) = intent.declared_quote() { - if !solvers::plausible_quote(quoted, settled_amount_out) { - intent.clear_quote(); - } +/// Two ways it can already be inside: +/// +/// - The solver's own event stated the output outright, and those figures are the swap's gross +/// result before any cut (`LiFi`'s `toAmount` is documented and verified as gross in +/// `solvers::lifi`). Nothing was measured from a receipt, so there is nothing short by the fee. +/// - The receipt that was measured belongs to the address that then paid the fee wallet out of it — +/// a router named as the declared output recipient receives the gross amount and forwards the +/// cut, so its receipt is already gross. +fn output_already_gross( + flow: &SettledSwap, + fee: &attribution::VenueFee, + declared: Option<&DeclaredSwap>, + transfer_ledger: &TransferLedger, + sender: Address, +) -> bool { + if declared.is_some_and(|declared| declared.amount_out.is_some()) { + return true; } - Some(intent) + let anchor = match declared { + Some(declared) => declared + .output_recipient + .unwrap_or(sender), + None => flow.tracked, + }; + transfer_ledger.sent_by_address(anchor, flow.token_out) >= fee.amount } -fn intent_fields(intent: Option<&SwapIntent>) -> (Option, Option, Option) { - let min_amount_out = intent.map(|intent| intent.min_amount_out); - let declared_quote = intent.and_then(SwapIntent::declared_quote); - let quote_timestamp = intent.and_then(|intent| intent.timestamp); - (min_amount_out, declared_quote, quote_timestamp) +/// The terms the solver declared alongside the trade, or all-`None` for a netted record where no +/// solver was read. Split out of `decode_transaction` purely to keep it under the line limit. +fn declared_terms(declared: Option<&DeclaredSwap>) -> (Option, Option, Option) { + ( + declared.and_then(|declared| declared.min_amount_out), + declared.and_then(|declared| declared.declared_quote), + declared.and_then(|declared| declared.timestamp), + ) } -/// Max concurrent trace requests per block. Bounds RPC load so a block -/// with many solver trades still completes within the block time -/// without tripping provider rate limits. -const TRACE_CONCURRENCY: usize = 10; - /// Stateful trade decoder: owns the RPC provider, the chain's address /// registry, and the caches that are worth keeping across blocks. pub(crate) struct Decoder

{ @@ -220,13 +274,11 @@ impl Decoder

{ /// Decode solver trades from a block. /// - /// Fetches all receipts in one `eth_getBlockReceipts` call, then matches a - /// transaction two ways: its entry point (`tx.to`) is a known venue or - /// solver, or one of its logs was emitted by a known solver. The - /// second case catches filler-initiated intent fills (`UniswapX`, 1inch - /// limit orders) where `tx.to` is a rotating filler. Matched transactions are - /// traced concurrently; the trace recovers native ETH flows and attributes - /// the settling solver. + /// Fetches all receipts in one `eth_getBlockReceipts` call and all traces in one + /// `debug_traceBlockByNumber` call, then matches a transaction three ways: a known solver's + /// frame appears in its trace, its entry point (`tx.to`) is a known venue, solver, or batch + /// settler, or one of its logs was emitted by a known solver (filler-initiated intent fills, + /// where `tx.to` is a rotating filler). Everything else is skipped, never decoded. pub(crate) async fn decode_block( &mut self, block_number: u64, @@ -246,55 +298,51 @@ impl Decoder

{ .with_context(|| format!("failed to fetch receipts for block {block_number}"))? .ok_or_else(|| anyhow::anyhow!("block {block_number} not found"))?; - // Paired with each receipt's position in the slice, since that position — not the - // transaction_index field, which the RPC may omit — is what "neighbor" means for the - // sandwich scan below: receipts are already in block order. - let matched: Vec<(usize, MatchedSolverTrade)> = receipts - .iter() - .enumerate() - .filter_map(|(index, receipt)| { - matching::select(receipt, &self.registry).map(|matched| (index, matched)) - }) - .collect(); - - // Per-block batch: trace every matched tx concurrently (bounded), - // collected in block order for deterministic output. Wall-clock cost is - // one receipts call plus the slowest trace wave — not the sum of every - // request — so a block stays well inside its block time. - // - // Failures are collected per transaction rather than aborting the wave: one transaction the - // RPC cannot trace costs that trade, not the whole block. Failing the block instead drops - // its every trade from the aggregates, and the surviving sample is selected by which - // transactions the RPC happened to serve. - let traces = futures::stream::iter( - matched + // One debug_traceBlockByNumber call covers the block. A transaction the tracer could not + // process is absent from the map and costs that trade, not the block. + let mut roots = fetch_block_traces(&self.provider, block_number).await?; + + let mut trades = Vec::new(); + // The receipt's position in the slice — not the transaction_index field, which the RPC + // may omit — is what "neighbor" means for the sandwich scan below: receipts are already + // in block order. + for (index, receipt) in receipts.iter().enumerate() { + if !receipt.status() { + continue; + } + let Some(entry_point) = receipt.to else { continue }; + let known_entry = self.registry.is_known(entry_point) || + self.registry + .is_batch_settler(entry_point); + let solver_logged = receipt + .logs() .iter() - .map(|(_, m)| fetch_trace(&self.provider, m.receipt.transaction_hash)), - ) - .buffered(TRACE_CONCURRENCY) - .collect::>() - .await; - - let mut trades = Vec::with_capacity(matched.len()); - for ((index, matched), trace) in matched.into_iter().zip(traces) { - let tx_index = matched - .receipt - .transaction_index - .unwrap_or(index as u64); - let root = match trace { - Ok(root) => root, - Err(e) => { + .any(|log| self.registry.is_solver(log.address())); + + let Some(root) = roots.remove(&receipt.transaction_hash) else { + if known_entry || solver_logged { warn!( block = block_number, - tx = %matched.receipt.transaction_hash, - "skipping untraceable transaction: {e}" + tx = %receipt.transaction_hash, + "skipping transaction absent from the block trace" ); crate::telemetry::record_untraced_transaction(); - continue; } + continue; }; + // Matching: a known solver frame in the trace, a known entry point, or a known + // solver's log. Everything else is skipped, never decoded. + if !known_entry && + !solver_logged && + trace::find_solver_frame(&root, &self.registry).is_none() + { + continue; + } + let tx_index = receipt + .transaction_index + .unwrap_or(index as u64); if let Some(mut trade) = self - .decode_transaction(matched, &root, block_number, tx_index) + .decode_transaction(receipt, entry_point, &root, block_number, tx_index) .await { let evidence = sandwich::detect(&receipts, index, &trade, &self.registry); @@ -305,17 +353,17 @@ impl Decoder

{ Ok(trades) } - /// Decode one matched transaction from its trace: build the transfer ledger, run the decoders - /// for its entity, veto non-trades, attribute the solver, and account gas and quote. + /// Decode one matched transaction from its trace: build the transfer ledger, decode the swap + /// (declared first, netting fallback), veto non-trades, and attribute the solver and venue. async fn decode_transaction( &mut self, - matched: MatchedSolverTrade<'_>, + receipt: &AnyTransactionReceipt, + entry_point: Address, root: &CallFrame, block_number: u64, tx_index: u64, ) -> Option { let Self { provider, registry, code_cache } = self; - let MatchedSolverTrade { receipt, entry_point } = matched; let logs = receipt.logs(); let sender = receipt.from; @@ -323,93 +371,88 @@ impl Decoder

{ collect_native_transfers(root, &mut native); let transfer_ledger = TransferLedger::from_transaction(logs, &native); - let mut ctx = DecodeContext { - provider, - registry, - code_cache, - receipt, - entry_point, - transfer_ledger: &transfer_ledger, - input: &root.input, - root, - venue: None, + // The declared decode runs first: the settling solver's own data is the trusted reading. + // Netting is the fallback, and its records are marked. A solver that declares the + // transaction is not a swap at all vetoes it here, before netting gets a chance to pair + // its legs into a trade that never happened. + let read = match declared::declared_flow(root, registry, logs, &transfer_ledger, sender) { + Ok(read) => read, + Err(veto) => { + // Counted and logged rather than swallowed: a misparsed floor or an off-basis + // amount in any solver's decoder deletes real trades from here, and the operator + // would otherwise see only lower coverage with nothing naming the cause. + debug!( + tx = %receipt.transaction_hash, + venue = %registry.label(entry_point), + ?veto, + "the settling solver's own data rejected this transaction; skipping" + ); + crate::telemetry::record_veto(&format!("{veto:?}")); + return None; + } }; - let Some((decoder, mut flow)) = recover(&mut ctx).await else { - warn!( - tx = %receipt.transaction_hash, - venue = %registry.label(entry_point), - "no decoder recovered a trade from this transaction" - ); - return None; + let (decoder, mut flow, declared) = if let Some((decoder, flow, declared)) = read { + (decoder, flow, Some(declared)) + } else { + let netted = netting::fallback_flow( + provider, + code_cache, + registry, + &transfer_ledger, + sender, + entry_point, + ) + .await; + let Some((decoder, flow)) = netted else { + warn!( + tx = %receipt.transaction_hash, + venue = %registry.label(entry_point), + "no decoder recovered a trade from this transaction" + ); + return None; + }; + (decoder, flow, None) }; - if let Some(veto) = veto::check(&flow, &transfer_ledger, logs, registry) { + // The address `amount_out` was anchored on: the payee the solver's calldata named, or the + // trader when it named none. The fee-on-transfer test exempts it (see `veto::check`). + let payee = declared + .as_ref() + .and_then(|declared| declared.output_recipient) + .unwrap_or(flow.tracked); + if let Some(veto) = veto::check(&flow, &transfer_ledger, logs, registry, payee) { debug!( tx = %receipt.transaction_hash, venue = %registry.label(entry_point), ?veto, "decoded flow is not a comparable trade; skipping" ); + crate::telemetry::record_veto(&format!("{veto:?}")); return None; } - // A venue fingerprint (owning trader, CoW appData tag, fee wallet, or integrator tag — see - // `venue_attribution`) overrides the entry-point label, backing any venue fee out before - // the quote check reads the grossed output. The appData tag is read from a batch settler's - // calldata; other transactions carry none. - let integrator = solvers::integrator(logs); - let app_data = intents::venue_tag(registry, entry_point, &root.input); - let venue = venue_attribution::attribute( - registry, - &mut flow, - &transfer_ledger, - integrator.as_deref(), - app_data, - ) - .unwrap_or_else(|| registry.label(entry_point)); - - let attribution = solvers::attribution::attribute( - flow.solver_override.take(), - root, - entry_point, - sender, - registry, - ); - - // A frontend routing through the solver can take a cut of the output without owning a - // venue section, declaring its fee recipients in the solver's own calldata. Backed out - // here so the settled output is gross, like Fynd's re-solve; a no-op when a venue - // fingerprint already accounted an output fee. - if let Some(fee) = solvers::declared_output_fee( - &attribution.solver, - &root.input, - &transfer_ledger, - flow.swap.token_out, - ) { - flow.gross_output_fee(fee); - } - - // Gas the trader paid for the settled route, as a wei cost. The flow's gas scope says - // which gas that is — see `GasScope`. - let settled_gas = match flow.gas_scope { - GasScope::WholeTransaction => Some(U256::from(receipt.gas_used)), - GasScope::SolverFrame => route_gas(root, registry), - GasScope::NotCharged => None, + let attribution = attribution::solver(root, entry_point, sender, registry); + + // A venue fingerprint overrides the entry-point label (see `attribution`). Two of the four + // are read from the settling solver's own data, dispatched through its address-book entry + // so this file names no solver; the other two are pure address-book lookups. + let tag = attribution + .address + .and_then(|address| registry.solver(address)) + .and_then(|solver| { + solver + .decoder + .venue_fingerprint(&root.input, logs) + }); + let fee = attribution::venue_fee(registry, &transfer_ledger, flow.token_in, flow.token_out); + let venue = attribution::venue(registry, &flow, tag.as_ref(), fee.as_ref()) + .unwrap_or_else(|| registry.label(entry_point)); + if let Some(fee) = &fee { + apply_venue_fee(&mut flow, fee, declared.as_ref(), &transfer_ledger, sender); } - .map(|units| units * U256::from(receipt.effective_gas_price)); - - // The calldata-declared swap terms (see `solver_intent`). Only the netting amounts above - // stay authoritative for what actually settled; these are informational. - let intent = solver_intent( - root, - registry, - &attribution.solver, - flow.swap.amount_in, - flow.swap.amount_out, - ); - warn_on_intent_disagreement(decoder, receipt.transaction_hash, intent.as_ref(), &flow); - let (min_amount_out, declared_quote, quote_timestamp) = intent_fields(intent.as_ref()); + let (min_amount_out, declared_quote, quote_timestamp) = declared_terms(declared.as_ref()); + let decode = decoder.tier(); Some(DecodedTrade { tx_hash: receipt.transaction_hash, @@ -419,14 +462,12 @@ impl Decoder

{ solver: attribution.solver, solver_source: attribution.source, decoder, + decode, sender: flow.tracked, - token_in: flow.swap.token_in, - token_out: flow.swap.token_out, - amount_in: flow.swap.amount_in, - amount_out: flow.swap.amount_out, - venue_fee_in: flow.venue_fee_in, - venue_fee_out: flow.venue_fee_out, - settled_gas, + token_in: flow.token_in, + token_out: flow.token_out, + amount_in: flow.amount_in, + amount_out: flow.amount_out, min_amount_out, declared_quote, quote_timestamp, @@ -451,6 +492,159 @@ mod tests { /// matches on its entry point alone. const ONEINCH: Address = address!("0x111111125421ca6dc452d289314280a0f8842a65"); + /// Phantom's 85 bps cut of the buy token, as `attribution` reports it. + fn output_fee(amount: u64) -> attribution::VenueFee { + attribution::VenueFee { + venue: "phantom".to_string(), + side: attribution::FeeSide::Output, + amount: U256::from(amount), + } + } + + #[test] + fn test_output_fee_is_added_back_to_a_netted_receipt() { + // The trader's receipt is short of the gross output by the fee, and Fynd's quote is gross. + let user = addr(1); + let pool = addr(0x50); + let phantom = addr(0x99); + let (token_in, token_out) = (addr(0xaa), addr(0xbb)); + let logs = vec![ + make_transfer_log(token_out, pool, user, U256::from(9915)), + make_transfer_log(token_out, pool, phantom, U256::from(85)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let mut flow = SettledSwap { + tracked: user, + token_in, + amount_in: U256::from(1000), + token_out, + amount_out: U256::from(9915), + }; + + apply_venue_fee(&mut flow, &output_fee(85), None, &ledger, user); + assert_eq!(flow.amount_out, U256::from(10_000)); + } + + #[test] + fn test_output_fee_is_not_added_to_an_amount_the_solvers_event_stated() { + // An event states the swap's gross output (LiFi's `toAmount`), so nothing was measured + // from a receipt and nothing is short by the fee. Adding it would count the cut twice. + let user = addr(1); + let pool = addr(0x50); + let phantom = addr(0x99); + let (token_in, token_out) = (addr(0xaa), addr(0xbb)); + let logs = vec![ + make_transfer_log(token_out, pool, user, U256::from(9915)), + make_transfer_log(token_out, pool, phantom, U256::from(85)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let declared = DeclaredSwap::from_event( + user, + token_in, + U256::from(1000), + token_out, + U256::from(10_000), + ); + let mut flow = SettledSwap { + tracked: user, + token_in, + amount_in: U256::from(1000), + token_out, + amount_out: U256::from(10_000), + }; + + apply_venue_fee(&mut flow, &output_fee(85), Some(&declared), &ledger, user); + assert_eq!(flow.amount_out, U256::from(10_000)); + } + + #[test] + fn test_output_fee_is_not_added_when_the_declared_recipient_paid_it() { + // The calldata named the router as the output recipient: it received the gross 10000 and + // paid the fee wallet out of it, so the receipt already contains the cut. + let user = addr(1); + let pool = addr(0x50); + let router = addr(0x60); + let phantom = addr(0x99); + let (token_in, token_out) = (addr(0xaa), addr(0xbb)); + let logs = vec![ + make_transfer_log(token_out, pool, router, U256::from(10_000)), + make_transfer_log(token_out, router, phantom, U256::from(85)), + make_transfer_log(token_out, router, user, U256::from(9915)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let declared = + DeclaredSwap::from_calldata(token_in, token_out, U256::from(1000), U256::from(9000)) + .with_recipient(router); + let mut flow = SettledSwap { + tracked: user, + token_in, + amount_in: U256::from(1000), + token_out, + amount_out: U256::from(10_000), + }; + + apply_venue_fee(&mut flow, &output_fee(85), Some(&declared), &ledger, user); + assert_eq!(flow.amount_out, U256::from(10_000)); + } + + #[test] + fn test_input_fee_is_subtracted_whatever_produced_the_amount() { + // The pools saw 9905 of the 10000 the trader authorized, so the re-solve is quoted 9905. + let user = addr(1); + let coinbase = addr(0x99); + let (token_in, token_out) = (addr(0xaa), addr(0xbb)); + let logs = vec![make_transfer_log(token_in, user, coinbase, U256::from(95))]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let fee = attribution::VenueFee { + venue: "coinbase".to_string(), + side: attribution::FeeSide::Input, + amount: U256::from(95), + }; + let mut flow = SettledSwap { + tracked: user, + token_in, + amount_in: U256::from(10_000), + token_out, + amount_out: U256::from(2000), + }; + + apply_venue_fee(&mut flow, &fee, None, &ledger, user); + assert_eq!(flow.amount_in, U256::from(9905)); + assert_eq!(flow.amount_out, U256::from(2000)); + } + + #[test] + fn test_decode_columns_keep_their_wire_strings() { + // These strings are the JSONL columns the Grafana dashboard and the offline report read, + // and historical files carry them. A variant rename must not move them, so the mapping is + // pinned here rather than left to the serde attributes alone. + let source = |value: DecodeSource| serde_json::to_string(&value).unwrap(); + assert_eq!(source(DecodeSource::DeclaredFromCalldata), r#""solver-calldata""#); + assert_eq!(source(DecodeSource::DeclaredFromLogs), r#""solver-logs""#); + assert_eq!(source(DecodeSource::VenueNetting), r#""venue-netting""#); + assert_eq!(source(DecodeSource::SenderNetting), r#""sender-netting""#); + assert_eq!(source(DecodeSource::IntentNetting), r#""intent-netting""#); + + assert_eq!(serde_json::to_string(&DecodeTier::Declared).unwrap(), r#""declared""#); + assert_eq!(serde_json::to_string(&DecodeTier::Netted).unwrap(), r#""netted""#); + // `wire` is what the report compares historical strings against, so it must agree with + // what serde writes. + assert_eq!(DecodeTier::Declared.wire(), "declared"); + assert_eq!(DecodeTier::Netted.wire(), "netted"); + } + + #[test] + fn test_every_decoder_maps_to_the_tier_its_name_states() { + for source in [DecodeSource::DeclaredFromCalldata, DecodeSource::DeclaredFromLogs] { + assert_eq!(source.tier(), DecodeTier::Declared, "{source:?}"); + } + for source in + [DecodeSource::VenueNetting, DecodeSource::SenderNetting, DecodeSource::IntentNetting] + { + assert_eq!(source.tier(), DecodeTier::Netted, "{source:?}"); + } + } + /// A sender-netting swap through `ONEINCH`: `sender` pays one token and is paid another. fn swap_receipt(hash: TxHash, sender: Address) -> AnyTransactionReceipt { let pool = addr(0x50); @@ -467,13 +661,22 @@ mod tests { #[tokio::test] async fn test_untraceable_transaction_does_not_drop_the_block() { + use alloy::rpc::types::trace::{common::TraceResult, geth::GethTrace}; + let asserter = Asserter::new(); asserter.push_success(&vec![ swap_receipt(tx_hash(1), addr(1)), swap_receipt(tx_hash(2), addr(2)), ]); - asserter.push_failure_msg("debug_traceTransaction unavailable"); - asserter.push_success(&frame("CALL", addr(2), ONEINCH, 0)); + // The block trace answers in one call: the first transaction failed inside the tracer, + // the second traced fine. + asserter.push_success(&vec![ + TraceResult::Error { error: "tracer aborted".to_string(), tx_hash: Some(tx_hash(1)) }, + TraceResult::Success { + result: GethTrace::CallTracer(frame("CALL", addr(2), ONEINCH, 0)), + tx_hash: Some(tx_hash(2)), + }, + ]); let mut decoder = Decoder::new( ProviderBuilder::default().connect_mocked_client(asserter), @@ -488,4 +691,79 @@ mod tests { assert_eq!(trades.len(), 1); assert_eq!(trades[0].tx_hash, tx_hash(2)); } + + #[tokio::test] + async fn test_venue_fingerprint_reaches_the_settling_solvers_decoder() { + use alloy::{ + primitives::B256, + rpc::types::{ + trace::{common::TraceResult, geth::GethTrace}, + Log, + }, + sol_types::SolEvent, + }; + + use crate::decoder::solvers::lifi::LiFiGenericSwapCompleted; + + // An Infinex swap through the shared LiFi Diamond: the venue is named only by the + // integrator tag in LiFi's own event. The fixture names LiFi because it needs concrete + // bytes; the decode path under test does not — it asks whatever decoder the Diamond's + // address-book entry carries. So this asserts the dispatch, not the parse (`lifi.rs` + // tests that). + let lifi = address!("0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae"); + let trader = addr(1); + let pool = addr(0x50); + let (token_in, token_out) = (addr(0xaa), addr(0xbb)); + + let swap_event = LiFiGenericSwapCompleted { + transactionId: B256::ZERO, + integrator: "infinex".to_string(), + referrer: String::new(), + receiver: trader, + fromAssetId: token_in, + toAssetId: token_out, + fromAmount: U256::from(1_000), + toAmount: U256::from(2_000), + }; + let log_data = swap_event.encode_log_data(); + let lifi_log = Log { + inner: alloy::primitives::Log::new_unchecked( + lifi, + log_data.topics().to_vec(), + log_data.data.clone(), + ), + ..Default::default() + }; + + let asserter = Asserter::new(); + asserter.push_success(&vec![receipt( + tx_hash(1), + trader, + Some(lifi), + vec![ + make_transfer_log(token_in, trader, pool, U256::from(1_000)), + make_transfer_log(token_out, pool, trader, U256::from(2_000)), + lifi_log, + ], + )]); + let traces: Vec> = vec![TraceResult::Success { + result: GethTrace::CallTracer(frame("CALL", trader, lifi, 0)), + tx_hash: Some(tx_hash(1)), + }]; + asserter.push_success(&traces); + + let mut decoder = Decoder::new( + ProviderBuilder::default().connect_mocked_client(asserter), + Registry::ethereum(), + ); + let trades = decoder + .decode_block(21_000_000) + .await + .unwrap(); + + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].solver, "lifi"); + // Without the fingerprint dispatch this reads "lifi" — the router, not the frontend. + assert_eq!(trades[0].venue, "infinex"); + } } diff --git a/tools/hindsight/src/decoder/netting.rs b/tools/hindsight/src/decoder/netting.rs new file mode 100644 index 000000000..2e963a883 --- /dev/null +++ b/tools/hindsight/src/decoder/netting.rs @@ -0,0 +1,393 @@ +//! Transfer-netting: recover a swap from what actually moved. +//! +//! The evidence is the ERC-20 `Transfer` events plus the native transfers recovered from the +//! trace (see `transfer_ledger`) — what actually moved, not what any contract or calldata +//! declared. It needs no knowledge of any router's format, which is also its weakness: a venue fee +//! taken out of the trade sits inside the netted amounts, since netting reads the trader's gross +//! spend and receipt. A fee paid to a wallet in `[venue_fees]` is corrected out by +//! `super::attribution::venue`; any other fee stays inside. Netted records are therefore the +//! marked fallback tier (`decode: "netted"`), excluded from the report by default, and the +//! declared decode (see `super::declared`) is the trusted path. +//! +//! Netting requires the trader to both pay and receive. When the swap's output is delivered to a +//! different receiver, nothing nets against the trader's input and the transaction is declined — +//! a coverage miss, never wrong amounts (see `transfer_ledger` for the model's assumptions). + +use std::collections::HashMap; + +use alloy::{primitives::Address, providers::Provider}; +use tracing::warn; + +use crate::decoder::{ + registry::Registry, + transfer_ledger::{SettledSwap, TransferLedger}, + DecodeSource, +}; + +/// Net the trade the declared decode could not read, picking whose balances count as the trade +/// from the entry point: +/// +/// - a venue entry or a solver entry is a direct swap: the sender is the trader; +/// - a batch settlement or a log-matched intent fill is sent by a solver, so the trader is found in +/// the transfers instead. +/// +/// Returns the decoder label recorded on the trade with the flow. +pub(crate) async fn fallback_flow( + provider: &P, + code_cache: &mut HashMap, + registry: &Registry, + transfer_ledger: &TransferLedger, + sender: Address, + entry_point: Address, +) -> Option<(DecodeSource, SettledSwap)> { + if registry + .venue_name(entry_point) + .is_some() + { + return sender_flow(transfer_ledger, sender, entry_point) + .map(|flow| (DecodeSource::VenueNetting, flow)); + } + if registry.is_solver(entry_point) && !registry.is_batch_settler(entry_point) { + return sender_flow(transfer_ledger, sender, entry_point) + .map(|flow| (DecodeSource::SenderNetting, flow)); + } + // Batch settlements and log-matched intent fills: the sender acts for the trader, so the + // trader is found in the transfers. + if let Some(flow) = + find_intent_trade(provider, transfer_ledger, &[entry_point, sender], registry, code_cache) + .await + { + return Some((DecodeSource::IntentNetting, flow)); + } + // No trader found. Netting the sender instead is only right where the sender *is* the trader, + // which a batch settler's sender never is: it is a solver settling many orders, so its net + // flow is inventory and rounding rather than one swap (see `Registry::is_batch_settler`). + // Recording that as a trade invents amounts and compares them against a fresh quote, which + // is worse than the coverage miss. + if registry.is_batch_settler(entry_point) { + return None; + } + // A frame-matched transaction through an unknown wrapper has no other trader to find, and its + // sender is the trader. + sender_flow(transfer_ledger, sender, entry_point) + .map(|flow| (DecodeSource::SenderNetting, flow)) +} + +/// Net the sender's flow. When the sender nets nothing, fall back to the contract the transaction +/// entered through (`tx.to`), for the rare shape where the swap output is delivered to that +/// contract rather than back to the sender. +fn sender_flow( + transfer_ledger: &TransferLedger, + sender: Address, + entry_point: Address, +) -> Option { + transfer_ledger + .net_swap(sender) + .or_else(|| transfer_ledger.net_swap(entry_point)) +} + +/// Find the order swapper's trade in a solver-initiated intent fill. +/// +/// The transaction sender is the solver, not the swapper, so we look for the +/// externally-owned account whose net flow is a clean two-token swap. Contracts +/// never qualify (checked via `eth_getCode`): pools and routers net the inverse +/// swap or leftover dust, and recording an intermediary's dust as the trade +/// produces absurd "swaps" (seen live: WETH → 2.4e-7 AAVE). Known registry +/// contracts and the excluded addresses (solver, entry point) are skipped too. +/// A fill with no clean-net EOA is declined rather than guessed. +/// +/// v0 limitations (tracked for a decode/attribution rework): +/// - **One swapper per transaction.** The first clean-net EOA wins, so a batch that settles several +/// retail orders in one tx contributes a single decoded trade; the rest surface as "Allium only" +/// gaps and batch volume is under-counted. +/// - **No settlement-tied tiebreak.** When several non-excluded EOAs each net to a clean two-token +/// swap, the winner is just the first in `intent_candidates`' address-ordered iteration, so a +/// decode can attribute the wrong account's flow. +/// - **Smart-wallet swappers are declined.** A swapper behind contract code (account abstraction, +/// EIP-7702 delegation) is indistinguishable from a pool here, so its fills are dropped. +async fn find_intent_trade( + provider: &P, + transfer_ledger: &TransferLedger, + exclude: &[Address], + registry: &Registry, + code_cache: &mut HashMap, +) -> Option { + for flow in intent_candidates(transfer_ledger, exclude, registry) { + if !is_contract(provider, flow.tracked, code_cache).await { + return Some(flow); + } + } + None +} + +/// Addresses with a clean two-token net swap, excluding the zero address, the +/// excluded addresses, and known registry contracts. Ordered by address for +/// deterministic selection. +fn intent_candidates( + transfer_ledger: &TransferLedger, + exclude: &[Address], + registry: &Registry, +) -> Vec { + let mut candidates = transfer_ledger.participants(); + candidates.remove(&Address::ZERO); + candidates.retain(|address| !exclude.contains(address) && !registry.is_known(*address)); + + let mut flows = Vec::new(); + for candidate in candidates { + if let Some(flow) = transfer_ledger.net_swap(candidate) { + flows.push(flow); + } + } + flows +} + +/// Whether an address has contract code, cached across blocks. On RPC failure +/// the address is treated as a contract so it is not mistaken for an EOA +/// swapper. +/// +/// v0 limitation: an EIP-7702-delegated account carries code, so a 7702 swapper EOA is classified +/// as a contract and dropped. 7702 is not yet widely used, so this is accepted for now. +async fn is_contract( + provider: &P, + address: Address, + cache: &mut HashMap, +) -> bool { + if let Some(is_contract) = cache.get(&address) { + return *is_contract; + } + let is_contract = match provider.get_code_at(address).await { + Ok(code) => !code.is_empty(), + Err(error) => { + warn!(%address, %error, "failed to fetch code; treating as contract"); + true + } + }; + cache.insert(address, is_contract); + is_contract +} + +#[cfg(test)] +mod tests { + use alloy::{ + primitives::{Bytes, U256}, + providers::RootProvider, + rpc::client::RpcClient, + transports::mock::Asserter, + }; + + use super::*; + use crate::decoder::test_utils::{addr, make_transfer_log, swap}; + + fn mocked_provider(asserter: &Asserter) -> RootProvider { + RootProvider::new(RpcClient::mocked(asserter.clone())) + } + + fn relay_entry(registry: &Registry) -> Address { + *registry + .venue("relay") + .unwrap() + .entry_points + .iter() + .next() + .unwrap() + } + + /// The swapper/pool inverse-swap fixture: swapper sells `token_a` for `token_b`, pool nets the + /// inverse. + fn inverse_swap_ledger() -> TransferLedger { + let logs = vec![ + make_transfer_log(addr(10), addr(100), addr(101), U256::from(1000)), + make_transfer_log(addr(11), addr(101), addr(100), U256::from(2000)), + ]; + TransferLedger::from_transaction(&logs, &[]) + } + + #[tokio::test] + async fn test_fallback_venue_entry_nets_the_sender() { + // User swap through a venue entry point: the sender's own net flow is the trade. A venue + // fee taken from the input stays inside `amount_in` — the record is marked netted, and the + // marker is what carries that inaccuracy. + let registry = Registry::ethereum(); + let router = relay_entry(®istry); + let user = addr(1); + let pool = addr(50); + let token_in = addr(10); + let token_out = addr(11); + + let logs = vec![ + make_transfer_log(token_in, user, router, U256::from(1000)), + make_transfer_log(token_in, router, addr(99), U256::from(40)), + make_transfer_log(token_in, router, pool, U256::from(960)), + make_transfer_log(token_out, pool, user, U256::from(2000)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let provider = mocked_provider(&Asserter::new()); + let mut cache = HashMap::new(); + + let (decoder, flow) = + fallback_flow(&provider, &mut cache, ®istry, &ledger, user, router) + .await + .unwrap(); + assert_eq!(decoder, DecodeSource::VenueNetting); + assert_eq!(flow, SettledSwap { tracked: user, ..swap(token_in, 1000, token_out, 2000) }); + } + + #[tokio::test] + async fn test_fallback_direct_solver_nets_the_sender() { + let registry = Registry::ethereum(); + let oneinch: Address = "0x111111125421ca6dc452d289314280a0f8842a65" + .parse() + .unwrap(); + let user = addr(1); + let pool = addr(50); + let logs = vec![ + make_transfer_log(addr(10), user, pool, U256::from(1000)), + make_transfer_log(addr(11), pool, user, U256::from(2000)), + ]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let provider = mocked_provider(&Asserter::new()); + let mut cache = HashMap::new(); + + let (decoder, flow) = + fallback_flow(&provider, &mut cache, ®istry, &ledger, user, oneinch) + .await + .unwrap(); + assert_eq!(decoder, DecodeSource::SenderNetting); + assert_eq!(flow, SettledSwap { tracked: user, ..swap(addr(10), 1000, addr(11), 2000) }); + } + + #[tokio::test] + async fn test_fallback_batch_settler_finds_the_swapper() { + // A CoW batch the log decode declined (multi-order): the sender is the solver, so the + // trader is the clean-net EOA in the transfers. + let registry = Registry::ethereum(); + let cow: Address = "0x9008d19f58aabd9ed0d60971565aa8510560ab41" + .parse() + .unwrap(); + let asserter = Asserter::new(); + asserter.push_success(&Bytes::default()); // the swapper is an EOA + let provider = mocked_provider(&asserter); + let mut cache = HashMap::new(); + + let (decoder, flow) = + fallback_flow(&provider, &mut cache, ®istry, &inverse_swap_ledger(), addr(2), cow) + .await + .unwrap(); + assert_eq!(decoder, DecodeSource::IntentNetting); + assert_eq!(flow.tracked, addr(100)); + } + + #[tokio::test] + async fn test_fallback_batch_settler_with_no_swapper_is_declined() { + // The same CoW batch, but every candidate carries contract code, so no trader is found. + // The settler's sender is a solver: its net flow is inventory and rounding across many + // orders, not one swap. Netting it would invent a trade, so the transaction is declined. + let registry = Registry::ethereum(); + let cow: Address = "0x9008d19f58aabd9ed0d60971565aa8510560ab41" + .parse() + .unwrap(); + let asserter = Asserter::new(); + asserter.push_success(&Bytes::from(vec![0xfe])); + asserter.push_success(&Bytes::from(vec![0xfe])); + let provider = mocked_provider(&asserter); + let mut cache = HashMap::new(); + + assert!(fallback_flow( + &provider, + &mut cache, + ®istry, + &inverse_swap_ledger(), + addr(2), + cow + ) + .await + .is_none()); + } + + #[tokio::test] + async fn test_find_intent_trade_eoa_candidate() { + let asserter = Asserter::new(); + // Candidates in address order: addr(100) first — an EOA (empty code). + asserter.push_success(&Bytes::default()); + let provider = mocked_provider(&asserter); + + let registry = Registry::ethereum(); + let mut cache = HashMap::new(); + let flow = find_intent_trade(&provider, &inverse_swap_ledger(), &[], ®istry, &mut cache) + .await + .unwrap(); + assert_eq!( + flow, + SettledSwap { tracked: addr(100), ..swap(addr(10), 1000, addr(11), 2000) } + ); + } + + #[tokio::test] + async fn test_find_intent_trade_all_candidates_contracts() { + let asserter = Asserter::new(); + // Both candidates carry code: a routing intermediary and a pool. Guessing one would net + // residue dust as an absurd swap, so the fill must be declined. + asserter.push_success(&Bytes::from(vec![0xfe])); + asserter.push_success(&Bytes::from(vec![0xfe])); + let provider = mocked_provider(&asserter); + + let registry = Registry::ethereum(); + let mut cache = HashMap::new(); + let flow = + find_intent_trade(&provider, &inverse_swap_ledger(), &[], ®istry, &mut cache).await; + assert!(flow.is_none()); + } + + #[test] + fn test_intent_candidates_swap_sides() { + // Intent fill: the swapper sells token_a for token_b; the pool is the + // counterparty. The solver is excluded. + let registry = Registry::ethereum(); + let swapper = addr(100); + let pool = addr(101); + let solver = addr(102); + let token_a = addr(10); + let token_b = addr(11); + + let logs = vec![ + make_transfer_log(token_a, swapper, pool, U256::from(1000)), + make_transfer_log(token_b, pool, swapper, U256::from(2000)), + ]; + let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); + + let found: HashMap = intent_candidates(&transfer_ledger, &[solver], ®istry) + .into_iter() + .map(|flow| (flow.tracked, flow)) + .collect(); + assert_eq!(found.len(), 2); + assert_eq!( + found[&swapper], + SettledSwap { tracked: swapper, ..swap(token_a, 1000, token_b, 2000) } + ); + // The pool nets the inverse swap; the EOA filter discards it later. + assert_eq!( + found[&pool], + SettledSwap { tracked: pool, ..swap(token_b, 2000, token_a, 1000) } + ); + } + + #[test] + fn test_intent_candidates_excluded_and_known() { + let registry = Registry::ethereum(); + let swapper = addr(100); + let pool = addr(101); + let token_a = addr(10); + let token_b = addr(11); + + let logs = vec![ + make_transfer_log(token_a, swapper, pool, U256::from(1000)), + make_transfer_log(token_b, pool, swapper, U256::from(2000)), + ]; + let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); + + // Excluding the swapper leaves only the pool. + let candidates = intent_candidates(&transfer_ledger, &[swapper], ®istry); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].tracked, pool); + } +} diff --git a/tools/hindsight/src/decoder/netting_decoders.rs b/tools/hindsight/src/decoder/netting_decoders.rs deleted file mode 100644 index d2f5f76bb..000000000 --- a/tools/hindsight/src/decoder/netting_decoders.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Transfer-netting: recover a swap from what actually moved. -//! -//! The evidence is the ERC-20 `Transfer` events plus the native transfers recovered from the -//! trace (see `transfer_ledger`) — what actually moved, not what any contract or calldata -//! declared. It needs no knowledge of any router's format. -//! -//! This module is a toolkit plus one decoder. The toolkit — `sender_flow` and `venue_flow` — is -//! the shared netting engine the venue decoders build on. The decoder is `SenderNetting`, for -//! direct solver swaps; intent fills and batch settlements are decoded in `super::intents`. -//! -//! Netting requires the trader to both pay and receive. When the swap's output is delivered to a -//! different receiver, nothing nets against the trader's input and the transaction is declined — -//! a coverage miss, never wrong amounts (see `transfer_ledger` for the model's assumptions). - -use std::collections::HashSet; - -use alloy::{primitives::Address, providers::Provider}; -use async_trait::async_trait; - -use crate::decoder::{ - decode::{DecodeContext, GasScope, TradeDecoder, TraderFlow}, - transfer_ledger::{NetSwap, TransferLedger}, -}; - -/// Net the sender's flow. When the sender nets nothing, fall back to the contract the transaction -/// entered through (`tx.to`), for the rare shape where the swap output is delivered to that -/// contract rather than back to the sender. -/// -/// A sender-tracked flow charges the whole receipt's gas (the trader sent the transaction); the -/// fallback charges nothing, since the tracked contract and the gas-paying sender differ. -pub(crate) fn sender_flow( - transfer_ledger: &TransferLedger, - sender: Address, - entry_point: Address, -) -> Option { - transfer_ledger - .net_swap(sender) - .map(|swap| TraderFlow { - gas_scope: GasScope::WholeTransaction, - ..TraderFlow::without_fees(sender, swap) - }) - .or_else(|| { - transfer_ledger - .net_swap(entry_point) - .map(|swap| TraderFlow::without_fees(entry_point, swap)) - }) -} - -/// Net the sender's flow and back the venue's fee out of it — the shared shape of every -/// fee-taking venue entry. Venue decoders call this, then add what is specific to them. -/// -/// A trader-paid flow's gas scope narrows to the solver call's trace frame: inside a venue's -/// contract the receipt's gas includes the venue's own overhead, which is charged whichever solver -/// the venue picks and must stay out of the comparison. -/// -/// One exception to the fee back-out: when the tracked trader IS a fee collector, the transaction -/// is a treasury operation — the collector's receipts are its own output, not a fee, and backing -/// them "out" would add the output to itself and double it. -pub(crate) fn venue_flow( - transfer_ledger: &TransferLedger, - sender: Address, - entry_point: Address, - fee_collectors: &HashSet

, -) -> Option { - let mut flow = sender_flow(transfer_ledger, sender, entry_point)?; - if flow.gas_scope == GasScope::WholeTransaction { - flow.gas_scope = GasScope::SolverFrame; - } - if fee_collectors.contains(&flow.tracked) { - return Some(flow); - } - Some(back_out_venue_fees(flow, transfer_ledger, fee_collectors)) -} - -/// Back a venue fee out of a decoded user flow. -/// -/// The venue can take its fee on either side. An input-side fee is subtracted from `amount_in` -/// (the user's gross spend included money that never entered the swap) and an output-side fee is -/// added back into `amount_out` (the swap produced more than the user kept), so both sides are the -/// amounts actually swapped — the like-for-like basis vs Fynd. -fn back_out_venue_fees( - flow: TraderFlow, - transfer_ledger: &TransferLedger, - fee_collectors: &HashSet
, -) -> TraderFlow { - let fees = transfer_ledger.received_by(fee_collectors); - let venue_fee_in = fees - .get(&flow.swap.token_in) - .copied() - .filter(|fee| !fee.is_zero()); - let amount_in = - venue_fee_in.map_or(flow.swap.amount_in, |fee| flow.swap.amount_in.saturating_sub(fee)); - let venue_fee_out = fees - .get(&flow.swap.token_out) - .copied() - .filter(|fee| !fee.is_zero()); - let amount_out = - venue_fee_out.map_or(flow.swap.amount_out, |fee| flow.swap.amount_out.saturating_add(fee)); - TraderFlow { - tracked: flow.tracked, - swap: NetSwap { amount_in, amount_out, ..flow.swap }, - venue_fee_in, - venue_fee_out, - solver_override: flow.solver_override, - gas_scope: flow.gas_scope, - } -} - -/// Direct solver swaps: the sender is the trader, so net the sender's flow. -pub(crate) struct SenderNetting; - -#[async_trait] -impl TradeDecoder

for SenderNetting { - fn name(&self) -> &'static str { - "sender-netting" - } - - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - sender_flow(ctx.transfer_ledger, ctx.receipt.from, ctx.entry_point) - } -} diff --git a/tools/hindsight/src/decoder/registry.rs b/tools/hindsight/src/decoder/registry.rs index effae7b79..e35693ef6 100644 --- a/tools/hindsight/src/decoder/registry.rs +++ b/tools/hindsight/src/decoder/registry.rs @@ -77,9 +77,10 @@ struct AddressBook { venue_owners: HashMap, /// Fee-wallet address → venue, for venues that route through a shared router and are only /// identified by the fee transferred to their wallet (Phantom, Robinhood). Absent in books - /// with no fee-identified venues. + /// with no fee-identified venues. Ordered so a trade cut by two venues' wallets resolves to + /// the same venue on every run. #[serde(default)] - venue_fees: HashMap, + venue_fees: BTreeMap, /// Provider integrator tag → venue, for venues identified by the integrator string in a /// provider's event (`LiFi` frontends: Infinex, Robinhood). Keys are lowercase. Absent in /// books with no integrator-identified venues. @@ -91,44 +92,37 @@ struct AddressBook { venue_appdata: HashMap, } -/// A venue's address-book section on one chain: the contracts users enter through, the -/// collectors its fees are sent to, and its calldata solver aliases. Keyed by venue name in -/// the address book; the name binds to a decoder at load time (see -/// `crate::decoder::venues::decoders_for`). +/// A venue's address-book section on one chain: the contracts users enter through. Pure +/// addresses — a venue has no code, and no fee handling either: a fee is corrected only when its +/// wallet is listed in `[venue_fees]`, and only by `attribution::venue`. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct VenueAddresses { pub(crate) entry_points: HashSet

, - pub(crate) fee_collectors: HashSet
, - /// Lowercase substrings of the venue's calldata solver ids, mapped to the solver name used - /// in the address book. Ordered for deterministic matching; empty for venues that declare - /// no solver in calldata. - #[serde(default)] - solver_aliases: BTreeMap, } -impl VenueAddresses { - /// Normalize a solver id this venue declared in calldata to the address book's solver - /// names: the first alias substring (in table order) contained in the lowercased id names - /// the solver, trimming the venue's id decoration ("oneInchV6FeeDynamic" → "1inch") — not a - /// 1:1 rename. Unmatched ids pass through as-is: still more informative than a raw executor - /// address, and a signal to extend the address book. - pub(crate) fn normalize_solver(&self, id: &str) -> String { - let lower = id.to_lowercase(); - for (substring, name) in &self.solver_aliases { - if lower.contains(substring) { - return name.clone(); - } - } - id.to_string() +/// A loaded solver entry: its display name joined with its `SolverDecoder` implementation. +/// Built once per address-book load; at trade time `Registry::solver` hands it out by address, +/// so no name is ever matched on a hot path. +pub(crate) struct Solver { + pub(crate) name: String, + /// The solver's decoder — the no-op implementation for book-only solvers. + pub(crate) decoder: &'static dyn crate::decoder::solvers::SolverDecoder, +} + +impl std::fmt::Debug for Solver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Solver") + .field("name", &self.name) + .finish_non_exhaustive() } } /// Per-chain address book for trade decoding, loaded from TOML (see the module docs). #[derive(Debug)] pub(crate) struct Registry { - /// Solver routers — the venue that actually settles a swap. - solvers: HashMap, + /// Solver routers — the entries that actually settle a swap, each carrying its decoder. + solvers: HashMap, /// Display names of all registered solvers, for O(1) `is_solver_name` checks. solver_names: HashSet, /// Every known address (solvers and venues), for name resolution. @@ -156,8 +150,9 @@ pub(crate) struct Registry { /// rather than by the entry point (e.g. kpk's Safes settling through `CoW`). venue_owners: HashMap, /// Fee-wallet address → venue name, for venues identified by the fee they take on a shared - /// router rather than by the entry point (Phantom, Robinhood). - venue_fees: HashMap, + /// router rather than by the entry point (Phantom, Robinhood). Ordered for deterministic + /// attribution when two venues' wallets both take a cut of one trade. + venue_fees: BTreeMap, /// Provider integrator tag (lowercase) → venue name, for venues identified by the integrator /// string a provider records in its event (`LiFi` frontends: Infinex, Robinhood). venue_integrators: HashMap, @@ -201,21 +196,9 @@ impl Registry { } fn from_toml(text: &str) -> anyhow::Result { - let mut book: AddressBook = + let book: AddressBook = toml::from_str(text).context("failed to parse address book TOML")?; - // A venue section only carries addresses; its decoders are bound by name in code. An - // unbound name (a typo, or a venue with no decoder yet) must fail here — silently never - // decoding would just drop that venue's trades. - for name in book.venues.keys() { - if !crate::decoder::venues::has_decoder(name) { - anyhow::bail!( - "address book venue '{name}' has no decoder \ - (see venues::decoders_for for the recognized names)" - ); - } - } - let mut names = book.solvers.clone(); for (name, venue) in &book.venues { for &entry_point in &venue.entry_points { @@ -223,23 +206,23 @@ impl Registry { } } let solver_names = book.solvers.values().cloned().collect(); + let solvers = book + .solvers + .into_iter() + .map(|(address, name)| { + let decoder = crate::decoder::solvers::decoder_for(&name); + (address, Solver { name, decoder }) + }) + .collect(); let mut usd_stablecoins: Vec<(Address, u32)> = book .usd_stablecoins .into_iter() .collect(); usd_stablecoins.sort_unstable(); - // Alias substrings match against lowercased ids, so a mixed-case entry in the address - // book would silently never match — canonicalize at load. - for venue in book.venues.values_mut() { - venue.solver_aliases = std::mem::take(&mut venue.solver_aliases) - .into_iter() - .map(|(substring, name)| (substring.to_lowercase(), name)) - .collect(); - } Ok(Self { solver_names, - solvers: book.solvers, + solvers, names, batch_settlers: book.batch_settlers, labels: book.labels, @@ -267,11 +250,10 @@ impl Registry { self.solvers.contains_key(&address) } - /// The registered solver name for `address`, if any. - pub(crate) fn solver_name(&self, address: Address) -> Option<&str> { - self.solvers - .get(&address) - .map(String::as_str) + /// The loaded solver entry for a router address — name and decoder — if the address book has + /// one. + pub(crate) fn solver(&self, address: Address) -> Option<&Solver> { + self.solvers.get(&address) } /// Whether `name` is a registered solver's display name. Bounds the metric label @@ -300,15 +282,12 @@ impl Registry { self.infrastructure.contains(&address) || address == self.wrapped_native } - /// Whether the address is a registered fee collector: a venue section's collector or a - /// fee-identified venue wallet. Fees paid to these are venue fees — backed out by the venue - /// decoders and `venue_attribution` — not token-level transfer fees (see - /// `veto::Veto::FeeOnTransfer`). + /// Whether the address is a venue's fee wallet (`[venue_fees]`). A fee paid to one of these + /// is the venue's cut, not the token taxing its own transfers, so it must not read as + /// `veto::Veto::FeeOnTransfer`. Venue sections no longer carry their own collector list — + /// venue fees are not modelled — so `[venue_fees]` is the whole set. pub(crate) fn is_fee_collector(&self, address: Address) -> bool { - self.venue_fees.contains_key(&address) || - self.venues - .values() - .any(|venue| venue.fee_collectors.contains(&address)) + self.venue_fees.contains_key(&address) } /// The chain's `(stablecoin, decimals)` anchors for USD valuation. @@ -330,8 +309,8 @@ impl Registry { } /// Fee-wallet → venue map, for attributing venues identified only by their fee leg on a - /// shared router (see `crate::decoder::venue_attribution`). - pub(crate) fn venue_fees(&self) -> &HashMap { + /// shared router (see `crate::decoder::attribution`), in address order. + pub(crate) fn venue_fees(&self) -> &BTreeMap { &self.venue_fees } @@ -454,7 +433,7 @@ mod tests { registry .solvers .values() - .any(|name| name == "tycho"), + .any(|solver| solver.name == "tycho"), "{chain} has no tycho router" ); } @@ -496,38 +475,6 @@ mod tests { assert!(!registry.is_solver(relay)); } - #[test] - fn test_normalize_solver_metamask_and_unknown_ids() { - let registry = Registry::ethereum(); - let metamask = registry.venue("metamask").unwrap(); - assert_eq!(metamask.normalize_solver("oneInchV6FeeDynamic"), "1inch"); - assert_eq!(metamask.normalize_solver("uniswapPermit2FeeDynamic"), "uniswap"); - assert_eq!(metamask.normalize_solver("okx6"), "okx"); - assert_eq!(metamask.normalize_solver("someFutureSolver"), "someFutureSolver"); - } - - #[test] - fn test_solver_alias_venue_scoping() { - // The alias table is one venue's calldata names; a venue without one passes every - // id through unchanged. - let registry = Registry::ethereum(); - let relay = registry.venue("relay").unwrap(); - assert_eq!(relay.normalize_solver("oneInchV6FeeDynamic"), "oneInchV6FeeDynamic"); - } - - #[test] - fn test_mixed_case_alias_substring() { - // Alias substrings are canonicalized to lowercase at load, so a capitalized entry in the - // address book matches the same ids as a lowercase one. - let book = ETHEREUM_TOML.replace( - "[venues.metamask.solver_aliases]", - "[venues.metamask.solver_aliases]\nBeBop = \"bebop\"", - ); - let registry = Registry::from_toml(&book).unwrap(); - let metamask = registry.venue("metamask").unwrap(); - assert_eq!(metamask.normalize_solver("bebopJamV2"), "bebop"); - } - #[test] fn test_infrastructure_permit2_and_wrapped_native() { let registry = Registry::ethereum(); @@ -551,12 +498,7 @@ mod tests { fn test_venue_section_lookup_by_name_and_entry_point() { let registry = Registry::ethereum(); let relay = registry.venue("relay").unwrap(); - let collector = address!("0xf70da97812cb96acdf810712aa562db8dfa3dbef"); let router = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); - assert!(relay - .fee_collectors - .contains(&collector)); - assert!(!relay.fee_collectors.contains(&router)); assert!(relay.entry_points.contains(&router)); assert_eq!(registry.venue_name(router), Some("relay")); @@ -564,32 +506,13 @@ mod tests { registry.venue_name(address!("0x881d40237659c251811cec9c364ef91dc08d300c")), Some("metamask") ); - assert_eq!(registry.venue_name(collector), None); + assert_eq!( + registry.venue_name(address!("0xf70da97812cb96acdf810712aa562db8dfa3dbef")), + None + ); assert!(registry.venue("kyberswap").is_none()); } - #[test] - fn test_is_fee_collector() { - let registry = Registry::ethereum(); - let relay_collector = address!("0xf70da97812cb96acdf810712aa562db8dfa3dbef"); - let phantom_wallet = address!("0x2cffed5d56eb6a17662756ca0fdf350e732c9818"); - assert!(registry.is_fee_collector(relay_collector)); - assert!(registry.is_fee_collector(phantom_wallet)); - assert!(!registry.is_fee_collector(addr(123))); - } - - #[test] - fn test_venue_without_decoder() { - // A venue section whose name has no decoder would silently never decode, so the - // address book must fail to load. - let text = - format!("{ETHEREUM_TOML}\n[venues.reiay]\nentry_points = []\nfee_collectors = []\n"); - let err = Registry::from_toml(&text) - .unwrap_err() - .to_string(); - assert!(err.contains("no decoder"), "unexpected error: {err}"); - } - #[test] fn test_label_known_and_unknown() { let registry = Registry::ethereum(); diff --git a/tools/hindsight/src/decoder/registry/arbitrum.toml b/tools/hindsight/src/decoder/registry/arbitrum.toml index 4a6ed5c0e..74afc6078 100644 --- a/tools/hindsight/src/decoder/registry/arbitrum.toml +++ b/tools/hindsight/src/decoder/registry/arbitrum.toml @@ -2,11 +2,11 @@ # see it and registry.rs for what each section drives. Addresses gathered 2026-07-29. # # Accuracy note: a wrong or missing *solver* address only costs coverage (the swap fails to -# match), but a wrong *venue fee collector* produces wrong records (the fee stays inside the -# amounts). Every contract below was checked to be deployed on Arbitrum, and every venue was -# confirmed live by its own on-chain fingerprint (2026-07-29): MetaMask by a decoded swap paying -# 0xe3478b0b…, Rabby and Relay and Robinhood by fee legs from settlement contracts, Rainbow by the -# router's own token transfers. +# match), but a wrong *venue fee wallet* produces wrong records: `[venue_fees]` drives both the +# venue label and the fee correction on the amounts. Every contract below was checked to be deployed +# on Arbitrum, and every venue was confirmed live by its own on-chain fingerprint (2026-07-29): +# MetaMask by a decoded swap paying 0xe3478b0b…, Rabby and Relay and Robinhood by fee legs from +# settlement contracts, Rainbow by the router's own token transfers. # # Coinbase has no `[venues.coinbase]` section, and does not need one: both swap proxies from the # Ethereum book have no code on Arbitrum and its fee wallet received nothing, matching how those @@ -59,6 +59,8 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45" = "uniswap" "0xe592427a0aece92de3edee1f18e0157c05861564" = "uniswap" "0x4752ba5dbc23f44d87826276bf6fd6b1c372ad24" = "uniswap" +# Universal Router 2.1.1 (developers.uniswap.org supported-chains table) +"0x8b844f885672f333bc0042cb669255f93a4c1e6b" = "uniswap" # OKX DEX routers (okxlabs/DEX-Router-EVM-V1 deployments plus OKX's own docs). Several versions # run concurrently; each was confirmed OKX by the Arbitrum TokenApproveProxy and WNativeRelayer # addresses embedded in its bytecode. @@ -78,9 +80,10 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] "0xc873fecbd354f5a56e00e710b90ef4201db2448d" = "camelot" "0x1f721e2e82f6676fce4ea07a5958cf098d339e18" = "camelot" -# Venues. Relay's Arbitrum contracts and fee collector are the same addresses as mainnet; its -# routers are the Cancun-EVM deployment. Verified live: 106 fee legs to 0xf70da978… in a 20k-block -# sample, and the router that funds Relay's fills is the same solver address as on mainnet. +# Venues. Relay's Arbitrum contracts are the same addresses as mainnet; its routers are the +# Cancun-EVM deployment. Verified live: 106 fee legs to 0xf70da978… in a 20k-block sample, and the +# router that funds Relay's fills is the same solver address as on mainnet. The fee legs are +# evidence of the deployment only — Relay is not in `[venue_fees]`, so its cut is not corrected. [venues.relay] entry_points = [ "0xf5042e6ffac5a625d4e7848e0b01373d8eb9e222", @@ -89,39 +92,26 @@ entry_points = [ "0xccc88a9d1b4ed6b0eaba998850414b24f1c315be", "0x58cc3e0aa6cd7bf795832a225179ec2d848ce3e7", ] -fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # MetaMask Swap Router on Arbitrum — the same proxy Base uses (0xdb9b1e94…), not the mainnet # router. Verified on-chain: a decoded swap through it paid its fee to 0xe3478b0b… (mainnet fee # wallet 1); mainnet wallet 2 took nothing in a 20k-block sample. [venues.metamask] entry_points = ["0xdb9b1e94b5b69df7e401ddbede43491141047db3"] -fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] -[venues.metamask.solver_aliases] -oneinch = "1inch" -zeroex = "0x" -uniswap = "uniswap" -okx = "okx" -kyber = "kyberswap" -paraswap = "paraswap" -airswap = "airswap" -openocean = "openocean" -hashflow = "hashflow" # Rabby SwapProxy — same address as mainnet. Verified on-chain: fee legs paid to 0xcd6b9800… # (mainnet fee wallet 1) by the proxy and by shared solver routers; the historical wallet # 0x39041f… is unused on Arbitrum. As on mainnet, only the proxy is an entry point — shared-router -# Rabby swaps are recognised by the fee leg (see rabby.rs), not by tx.to. +# Rabby swaps are labelled by the fee wallet in `[venue_fees]`, not by tx.to. [venues.rabby] entry_points = ["0x02e5be68d46dac0b524905bff209cf47ee6db2a9"] -fee_collectors = ["0xcd6b980029e6e6e0733ac8ec3e02be9410d09799"] # Rainbow's own router — same address as mainnet. Verified live by its own token transfers. -# Input-side fee read from calldata; no fee collector (see rainbow.rs). +# Its input-side fee is kept in the router, never transferred out, so it stays +# inside `amount_in`. [venues.rainbow] entry_points = ["0x00000000009726632680fb29d3f7a9734e3010e2"] -fee_collectors = [] # Venues identified by the fee they take on a shared router. Verified on-chain: 35 fee legs to # Robinhood's wallet in a 20k-block sample, every one paid by the 0x Settler contract (so the diff --git a/tools/hindsight/src/decoder/registry/base.toml b/tools/hindsight/src/decoder/registry/base.toml index a160ecb5b..33a910ab0 100644 --- a/tools/hindsight/src/decoder/registry/base.toml +++ b/tools/hindsight/src/decoder/registry/base.toml @@ -2,9 +2,9 @@ # and registry.rs for what each section drives. Addresses gathered 2026-07-21. # # Accuracy note: a wrong or missing *solver* address only costs coverage (the swap fails to -# match), but a wrong *venue fee collector* produces wrong records (the fee stays inside the -# amounts). Every venue fee collector below was verified on-chain against live Base swaps -# (2026-07-21), not assumed from the mainnet book. +# match), but a wrong *venue fee wallet* produces wrong records: `[venue_fees]` drives both the +# venue label and the fee correction on the amounts. Every `[venue_fees]` wallet below was +# verified on-chain against live Base swaps (2026-07-21), not assumed from the mainnet book. # Wrapped-native token: WETH on Base (canonical OP-stack predeploy). wrapped_native = "0x4200000000000000000000000000000000000006" @@ -44,6 +44,9 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] # Uniswap Universal Router (V1_2) and SwapRouter02 on Base "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad" = "uniswap" "0x2626664c2603336e57b271c5c0b26f421741e481" = "uniswap" +# Universal Router 2.0, 2.1.1 (developers.uniswap.org supported-chains table) +"0x6ff5693b99212da76ad316178a184ab56d299b43" = "uniswap" +"0xfdf682f51fe81aa4898f0ae2163d8a55c127fbc7" = "uniswap" # OKX DEX Router on Base — router and exactOut router (OKX onchain-OS docs) "0x5e2f47bd7d4b357fcfd0bb224eb665773b1b9801" = "okx" "0x77449ff075c0a385796da0762bcb46fd5cc884c6" = "okx" @@ -53,9 +56,10 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] # (docs.fly.trade/developers/deployments) "0x20f6ee51340adeed01a59b0e65cb3703f3dc860c" = "fly" -# Venues. Relay's Base contracts and fee collector are the same addresses as mainnet — Relay docs -# list the Base solver/fee collector as 0xf70da978…, so its fee back-out is verified. Its routers -# are the Cancun-EVM deployment (Base is Cancun), identical to the mainnet book. +# Venues. Relay's Base contracts are the same addresses as mainnet — Relay docs list the Base +# solver as 0xf70da978…, which is how the deployment was confirmed. Its routers are the Cancun-EVM +# deployment (Base is Cancun), identical to the mainnet book. Relay is not in `[venue_fees]`, so +# its cut is not corrected out of the amounts. [venues.relay] entry_points = [ "0xf5042e6ffac5a625d4e7848e0b01373d8eb9e222", @@ -64,7 +68,6 @@ entry_points = [ "0xccc88a9d1b4ed6b0eaba998850414b24f1c315be", "0x58cc3e0aa6cd7bf795832a225179ec2d848ce3e7", ] -fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # MetaMask Swap Router on Base. Verified on-chain: 25/25 sampled fee-paying swaps entered through # 0xdb9b1e94… (a contract), paying the fee to 0xe3478b0b… — the mainnet fee wallet 1; mainnet @@ -72,33 +75,20 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # 0xb1aa0a09d43b6b4f289ef14f2441339acdb551ac is NOT the live entry point on Base. [venues.metamask] entry_points = ["0xdb9b1e94b5b69df7e401ddbede43491141047db3"] -fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] -[venues.metamask.solver_aliases] -oneinch = "1inch" -zeroex = "0x" -uniswap = "uniswap" -okx = "okx" -kyber = "kyberswap" -paraswap = "paraswap" -airswap = "airswap" -openocean = "openocean" -hashflow = "hashflow" # Rabby SwapProxy — same address as mainnet, deployed on Base. Verified on-chain: 837 fee legs in # a 10k-block sample were paid to 0xcd6b9800… (mainnet fee wallet 1) from the proxy and from # shared solver routers (1inch, 0x, KyberSwap, Uniswap); the mainnet historical wallet 0x39041f… # is unused on Base. As on mainnet, only the proxy is an entry point — shared-router Rabby swaps -# are recognised by the fee leg (see rabby.rs), not by tx.to. +# are labelled by the fee wallet in `[venue_fees]`, not by tx.to. [venues.rabby] entry_points = ["0x02e5be68d46dac0b524905bff209cf47ee6db2a9"] -fee_collectors = ["0xcd6b980029e6e6e0733ac8ec3e02be9410d09799"] # Rainbow's own router — same address as mainnet (Rainbow deploys it identically across chains). -# Input-side fee read from calldata; no fee collector (see rainbow.rs). +# Its input-side fee is kept in the router, never transferred out, so it stays inside `amount_in`. [venues.rainbow] entry_points = ["0x00000000009726632680fb29d3f7a9734e3010e2"] -fee_collectors = [] # Venues identified by the fee they take on a shared router. Coinbase's Base App is the heaviest # such venue on Base by a wide margin — 2942 fee legs in an 8k-block sample, 2292 paid by the LiFi diff --git a/tools/hindsight/src/decoder/registry/bsc.toml b/tools/hindsight/src/decoder/registry/bsc.toml index d470a1778..38b52154c 100644 --- a/tools/hindsight/src/decoder/registry/bsc.toml +++ b/tools/hindsight/src/decoder/registry/bsc.toml @@ -2,11 +2,11 @@ # see it and registry.rs for what each section drives. Addresses gathered 2026-07-29. # # Accuracy note: a wrong or missing *solver* address only costs coverage (the swap fails to -# match), but a wrong *venue fee collector* produces wrong records (the fee stays inside the -# amounts). Every contract below was checked to be deployed on BSC, and every venue was confirmed -# live by its own on-chain fingerprint (2026-07-29): MetaMask by a decoded swap paying -# 0xe3478b0b…, Rabby and Relay and Robinhood by fee legs from settlement contracts, Rainbow by the -# router's own token transfers. +# match), but a wrong *venue fee wallet* produces wrong records: `[venue_fees]` drives both the +# venue label and the fee correction on the amounts. Every contract below was checked to be deployed +# on BSC, and every venue was confirmed live by its own on-chain fingerprint (2026-07-29): MetaMask +# by a decoded swap paying 0xe3478b0b…, Rabby and Relay and Robinhood by fee legs from settlement +# contracts, Rainbow by the router's own token transfers. # # Coinbase has no `[venues.coinbase]` section, and does not need one: both swap proxies from the # Ethereum book have no code on BSC and its fee wallet received nothing, matching how those proxies @@ -72,6 +72,8 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad" = "uniswap" "0xb971ef87ede563556b2ed4b1c0b0019111dd85d2" = "uniswap" "0x4752ba5dbc23f44d87826276bf6fd6b1c372ad24" = "uniswap" +# Universal Router 2.1.1 (developers.uniswap.org supported-chains table) +"0x8b844f885672f333bc0042cb669255f93a4c1e6b" = "uniswap" # OKX DEX routers (okxlabs/DEX-Router-EVM-V1 deployments plus OKX's own docs). Several versions # run concurrently; each was confirmed OKX by the BSC TokenApproveProxy and WNativeRelayer # addresses embedded in its bytecode. @@ -95,9 +97,10 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] "0x1a0a18ac4becddbd6389559687d1a73d8927e416" = "pancakeswap" "0x10ed43c718714eb63d5aa57b78b54704e256024e" = "pancakeswap" -# Venues. Relay's BSC contracts and fee collector are the same addresses as mainnet; its routers -# are the Cancun-EVM deployment. Verified live: 666 fee legs to 0xf70da978… in a 20k-block sample, -# and a decoded Relay fill showed the same address funding the swap as on mainnet. +# Venues. Relay's BSC contracts are the same addresses as mainnet; its routers are the Cancun-EVM +# deployment. Verified live: 666 fee legs to 0xf70da978… in a 20k-block sample, and a decoded Relay +# fill showed the same address funding the swap as on mainnet. The fee legs are evidence of the +# deployment only — Relay is not in `[venue_fees]`, so its cut is not corrected. [venues.relay] entry_points = [ "0xf5042e6ffac5a625d4e7848e0b01373d8eb9e222", @@ -106,7 +109,6 @@ entry_points = [ "0xccc88a9d1b4ed6b0eaba998850414b24f1c315be", "0x58cc3e0aa6cd7bf795832a225179ec2d848ce3e7", ] -fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # MetaMask Swap Router on BSC — the same proxy Base and Arbitrum use (0xdb9b1e94…). Verified # on-chain: a decoded swap through it paid its fee to 0xe3478b0b… (mainnet fee wallet 1); mainnet @@ -114,32 +116,20 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # BSC but took no traffic in the sampled window, so it is not an entry point here. [venues.metamask] entry_points = ["0xdb9b1e94b5b69df7e401ddbede43491141047db3"] -fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] -[venues.metamask.solver_aliases] -oneinch = "1inch" -zeroex = "0x" -uniswap = "uniswap" -okx = "okx" -kyber = "kyberswap" -paraswap = "paraswap" -airswap = "airswap" -openocean = "openocean" -hashflow = "hashflow" # Rabby SwapProxy — same address as mainnet. Verified on-chain: 218 fee legs paid to 0xcd6b9800… # (mainnet fee wallet 1) in a 20k-block sample; the historical wallet 0x39041f… is unused on BSC. -# As on mainnet, only the proxy is an entry point — shared-router Rabby swaps are recognised by the -# fee leg (see rabby.rs), not by tx.to. +# As on mainnet, only the proxy is an entry point — shared-router Rabby swaps are labelled by the +# fee wallet in `[venue_fees]`, not by tx.to. [venues.rabby] entry_points = ["0x02e5be68d46dac0b524905bff209cf47ee6db2a9"] -fee_collectors = ["0xcd6b980029e6e6e0733ac8ec3e02be9410d09799"] # Rainbow's own router — same address as mainnet. Verified live by its own token transfers. -# Input-side fee read from calldata; no fee collector (see rainbow.rs). +# Its input-side fee is kept in the router, never transferred out, so it stays +# inside `amount_in`. [venues.rainbow] entry_points = ["0x00000000009726632680fb29d3f7a9734e3010e2"] -fee_collectors = [] # Venues identified by the fee they take on a shared router. Verified on-chain: 277 fee legs to # Robinhood's wallet in a 20k-block sample, paid by the 0x Settler contract (so the dust-spray diff --git a/tools/hindsight/src/decoder/registry/ethereum.toml b/tools/hindsight/src/decoder/registry/ethereum.toml index 555f53ac3..d184cfbad 100644 --- a/tools/hindsight/src/decoder/registry/ethereum.toml +++ b/tools/hindsight/src/decoder/registry/ethereum.toml @@ -42,8 +42,11 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] "0x9008d19f58aabd9ed0d60971565aa8510560ab41" = "cow" # ParaSwap Augustus v6.2 "0x6a000f20005980200259b80c5102003040001068" = "paraswap" -# Uniswap Universal Router +# Uniswap Universal Router v1.2, 2.0 and 2.1.1 (`execute`, selector 0x3593564c). Addresses per +# chain from developers.uniswap.org's supported-chains table. "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad" = "uniswap" +"0x66a9893cc07d91d95644aedd05d03f95e1dba8af" = "uniswap" +"0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca" = "uniswap" # UniswapX Dutch order reactor (filler-initiated; found via its log) "0x00000011f84b9aa48e5f8aa8b9897600006289be" = "uniswapx" # OKX DEX Router, and OKX DEX Router 6 (per OKX docs; the former is an older deployment) @@ -60,9 +63,9 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] # Fly (formerly Magpie) DexAggregator — same address on every chain (docs.fly.trade/developers/deployments) "0x20f6ee51340adeed01a59b0e65cb3703f3dc860c" = "fly" -# Venues — platforms that initiate a trade and route it through a solver found in the -# trace. Each section name must have a decode strategy in code (venues::Venue); -# `entry_points` are the contracts users enter through, `fee_collectors` where its fees are sent. +# Venues — platforms that initiate a trade and route it through a solver found in the trace. Pure +# addresses, no code: `entry_points` are the contracts users enter through. A venue's fees are not +# modelled; see `[venue_fees]` below for the wallets that identify a venue on a shared router. # Display names for entry points that are neither venues nor solvers — market-maker fillers, # solver contracts, bot routers. Label-only: these must NOT be venues or solvers, because @@ -100,47 +103,17 @@ entry_points = [ "0xccc88a9d1b4ed6b0eaba998850414b24f1c315be", "0x58cc3e0aa6cd7bf795832a225179ec2d848ce3e7", ] -# Relay fee collector (the Relay router takes the fee on the input side). Sole collector across a 25-tx -# on-chain sample; fee ranges ~1–41 bps depending on Relay's fee tier. -fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] [venues.metamask] # MetaMask Swap Router. entry_points = ["0x881d40237659c251811cec9c364ef91dc08d300c"] -# MetaMask fee wallets. Both observed in a 28-tx on-chain sample (26 paid one of the two, the -# rest were genuinely fee-free pairs); the fee is ~87.5 bps plus a gas recoup on gasless "smart -# swaps", taken from whichever swap side is native ETH, else from a swap token directly. -fee_collectors = [ - "0xe3478b0bb1a5084567c319096437924948be1964", - "0xf326e4de8f66a0bdc0970b79e0924e33c79f1915", -] -# MetaMask's router names the solver API behind each swap in a calldata aggregatorId like -# "oneInchV6FeeDynamic" or "okx6". Each entry maps a lowercase substring of that id to a solver -# name used in this address book; ids that match no entry are recorded as-is. -[venues.metamask.solver_aliases] -oneinch = "1inch" -zeroex = "0x" -uniswap = "uniswap" -okx = "okx" -kyber = "kyberswap" -paraswap = "paraswap" -airswap = "airswap" -openocean = "openocean" -hashflow = "hashflow" [venues.rabby] # Rabby SwapProxy — the app-owned router `tx.to` for its Uniswap-routed swaps. Rabby also routes # through shared solver routers (0x, 1inch, Sushi), where `tx.to` is the solver's own contract and -# the only Rabby fingerprint is the fee leg below; those are not keyed here (see rabby.rs). +# only the proxy is an entry point, so those swaps are labelled by the fee wallet below. entry_points = ["0x02e5be68d46dac0b524905bff209cf47ee6db2a9"] -# Rabby fee wallets: a flat 0.25% of the output token. Current wallet confirmed 2026-07-17 via two -# decoded live swaps through different venues; the historical wallet took the fee through ~Sep 2025 -# before rotating. Both are listed so trades in either window decode with the fee backed out. -fee_collectors = [ - "0xcd6b980029e6e6e0733ac8ec3e02be9410d09799", - "0x39041f1b366fe33f9a5a79de5120f2aee2577ebc", -] [venues.coinbase] # Coinbase Wallet's swap proxies — the app-owned `tx.to` for its 0x-powered swaps ("aggregation is @@ -151,15 +124,24 @@ entry_points = [ "0x8df6084e3b84a65ab9dd2325b5422e5debd8944a", "0xe66b31678d6c16e9ebf358268a790b763c133750", ] -# Coinbase's 0x integration fee, taken from the output token and sent to this wallet. -fee_collectors = ["0x382ffce2287252f930e1c8dc9328dac5bf282ba1"] [venues.rainbow] # Rainbow's own router ("Rainbow: Router"; same address on ETH/Base/Arb/OP/Polygon). It wraps 0x -# and takes its fee on the input side, passed as the call's feeAmount argument and kept by the -# router — no fee transfer, so the fee is read from calldata (see rainbow.rs). No fee collector. +# and takes its fee on the input side, keeping it in the router rather than transferring it out. +# With no transfer there is no fee wallet to match, so the cut stays inside `amount_in`. entry_points = ["0x00000000009726632680fb29d3f7a9734e3010e2"] -fee_collectors = [] + +[venues.definitive] +# Definitive (definitive.fi) — a non-custodial trading platform that mints one segregated trading +# vault per client per chain, all clones of the same 82-byte proxy, from its vault factory +# `0xdef00000cc0a125ba7962e8113d3b69786851736` ("Definitive: Vault Factory"). `tx.to` is the +# client's own vault, so every client is a separate address here and this list covers only the +# vaults observed so far — a Definitive trade through an unlisted vault stays unmatched. +# +# The transaction sender is never the client: Definitive routes each fill through a rotating proxy +# ("Performer Address") to mask order flow (docs.definitive.fi/institutions-and-funds), so the +# sender carries no venue signal and the vault is the only fingerprint. +entry_points = ["0x7957e5a88f0389997b352315e70ab20d77ec410a"] # Order-flow venues identified by the trader address that owns the order, not by tx.to. kpk is a # treasury manager whose Safes trade through CoW: tx.from is the CoW solver, so the venue is only diff --git a/tools/hindsight/src/decoder/registry/polygon.toml b/tools/hindsight/src/decoder/registry/polygon.toml index 065dfe106..485112c89 100644 --- a/tools/hindsight/src/decoder/registry/polygon.toml +++ b/tools/hindsight/src/decoder/registry/polygon.toml @@ -2,11 +2,11 @@ # see it and registry.rs for what each section drives. Addresses gathered 2026-07-29. # # Accuracy note: a wrong or missing *solver* address only costs coverage (the swap fails to -# match), but a wrong *venue fee collector* produces wrong records (the fee stays inside the -# amounts). Every contract below was checked to be deployed on Polygon, and every venue was -# confirmed live by its own on-chain fingerprint (2026-07-29): MetaMask and Phantom by decoded -# swaps paying their wallets, Rabby and Relay and Robinhood by fee legs from settlement contracts, -# Rainbow by the router's own token transfers. +# match), but a wrong *venue fee wallet* produces wrong records: `[venue_fees]` drives both the +# venue label and the fee correction on the amounts. Every contract below was checked to be deployed +# on Polygon, and every venue was confirmed live by its own on-chain fingerprint (2026-07-29): +# MetaMask and Phantom by decoded swaps paying their wallets, Rabby and Relay and Robinhood by fee +# legs from settlement contracts, Rainbow by the router's own token transfers. # # Coinbase has no `[venues.coinbase]` section, and does not need one: both swap proxies from the # Ethereum book have no code on Polygon and its fee wallet received nothing, matching how those @@ -58,6 +58,8 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad" = "uniswap" "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45" = "uniswap" "0xe592427a0aece92de3edee1f18e0157c05861564" = "uniswap" +# Universal Router 2.1.1 (developers.uniswap.org supported-chains table) +"0x8b844f885672f333bc0042cb669255f93a4c1e6b" = "uniswap" # OKX DEX routers (okxlabs/DEX-Router-EVM-V1 deployments plus OKX's own docs). Several versions run # concurrently; each was confirmed OKX by the Polygon TokenApproveProxy and WNativeRelayer # addresses embedded in its bytecode. 0xf6e1b4b2… is the one Phantom's decoded Polygon swap routed @@ -77,9 +79,10 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] "0xa5e0829caced8ffdd4de3c43696c57f7d7a678ff" = "quickswap" "0xf5b509bb0909a69b1c207e495f687a596c168e12" = "quickswap" -# Venues. Relay's Polygon contracts and fee collector are the same addresses as mainnet; its -# routers are the Cancun-EVM deployment. Verified live: 1416 fee legs to 0xf70da978… in a -# 10k-block sample — the heaviest Relay deployment of the chains added here. +# Venues. Relay's Polygon contracts are the same addresses as mainnet; its routers are the +# Cancun-EVM deployment. Verified live: 1416 fee legs to 0xf70da978… in a 10k-block sample — the +# heaviest Relay deployment of the chains added here. The fee legs are evidence of the deployment +# only — Relay is not in `[venue_fees]`, so its cut is not corrected. [venues.relay] entry_points = [ "0xf5042e6ffac5a625d4e7848e0b01373d8eb9e222", @@ -88,7 +91,6 @@ entry_points = [ "0xccc88a9d1b4ed6b0eaba998850414b24f1c315be", "0x58cc3e0aa6cd7bf795832a225179ec2d848ce3e7", ] -fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # MetaMask Swap Router on Polygon — the same proxy Base, Arbitrum and BSC use (0xdb9b1e94…), and # the busiest MetaMask deployment of the chains added here (1015 fee legs and 200 router @@ -96,32 +98,20 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # 0xe3478b0b… (mainnet fee wallet 1); mainnet wallet 2 took nothing. [venues.metamask] entry_points = ["0xdb9b1e94b5b69df7e401ddbede43491141047db3"] -fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] -[venues.metamask.solver_aliases] -oneinch = "1inch" -zeroex = "0x" -uniswap = "uniswap" -okx = "okx" -kyber = "kyberswap" -paraswap = "paraswap" -airswap = "airswap" -openocean = "openocean" -hashflow = "hashflow" # Rabby SwapProxy — same address as mainnet. Verified on-chain: 69 fee legs paid to 0xcd6b9800… # (mainnet fee wallet 1) in a 10k-block sample; the historical wallet 0x39041f… is unused on # Polygon. As on mainnet, only the proxy is an entry point — shared-router Rabby swaps are -# recognised by the fee leg (see rabby.rs), not by tx.to. +# labelled by the fee wallet in `[venue_fees]`, not by tx.to. [venues.rabby] entry_points = ["0x02e5be68d46dac0b524905bff209cf47ee6db2a9"] -fee_collectors = ["0xcd6b980029e6e6e0733ac8ec3e02be9410d09799"] # Rainbow's own router — same address as mainnet. Verified live by its own token transfers. -# Input-side fee read from calldata; no fee collector (see rainbow.rs). +# Its input-side fee is kept in the router, never transferred out, so it stays +# inside `amount_in`. [venues.rainbow] entry_points = ["0x00000000009726632680fb29d3f7a9734e3010e2"] -fee_collectors = [] # Venues identified by the fee they take on a shared router. Verified on-chain in a 10k-block # sample: 194 fee legs to Robinhood's wallet and 30 to Phantom's current wallet, every one paid by diff --git a/tools/hindsight/src/decoder/registry/robinhood.toml b/tools/hindsight/src/decoder/registry/robinhood.toml index 1460e272f..30b2f614e 100644 --- a/tools/hindsight/src/decoder/registry/robinhood.toml +++ b/tools/hindsight/src/decoder/registry/robinhood.toml @@ -2,10 +2,10 @@ # see it and registry.rs for what each section drives. Addresses gathered 2026-08-20. # # Accuracy note: a wrong or missing *solver* address only costs coverage (the swap fails to -# match), but a wrong *venue fee collector* produces wrong records (the fee stays inside the -# amounts). Every contract below was checked to be deployed on Robinhood Chain, and every venue was -# confirmed live by its own on-chain fee legs in a 100k-block sample (~28 hours at 1-second blocks) -# ending block 41578851. +# match), but a wrong *venue fee wallet* produces wrong records: `[venue_fees]` drives both the +# venue label and the fee correction on the amounts. Every contract below was checked to be deployed +# on Robinhood Chain, and every venue was confirmed live by its own on-chain fee legs in a +# 100k-block sample (~28 hours at 1-second blocks) ending block 41578851. # # Robinhood Chain is an Arbitrum Orbit (Nitro) rollup with ETH as the gas token, so several # contracts sit at chain-specific addresses rather than their mainnet ones. Each was re-derived @@ -111,7 +111,7 @@ batch_settlers = [] # Tycho (PropellerHeads) router V3 on Robinhood Chain (docs.propellerheads.xyz contract addresses) "0x345e48768a65ae596ac6a2aee71202753c4866f5" = "tycho" -# Venues. Relay's fee collector is the same address as mainnet; of its mainnet contracts only the +# Venues. Relay's fee wallet is the same address as mainnet; of its mainnet contracts only the # v3 router and the v3 approval proxy are deployed here, and they paid 193 of the 360 fee legs in # the sample. The rest came from Relay's Depository, which fills orders bridged in from another # chain rather than settling a same-chain swap, so it is not an entry point. @@ -120,36 +120,24 @@ entry_points = [ "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f", # RelayRouterV3 "0xccc88a9d1b4ed6b0eaba998850414b24f1c315be", # RelayApprovalProxyV3 ] -fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # MetaMask's Swap Router on this chain (see the header note). Only the second of its two mainnet fee # wallets is used here — 54 fee legs in the sample, all from this router; the first took nothing. [venues.metamask] entry_points = ["0xf5307745c61de69bf033edfe6a8771256275a323"] -fee_collectors = ["0xf326e4de8f66a0bdc0970b79e0924e33c79f1915"] - -# The aggregator ids this router named in the sample, mapped to the solver names above. "0xV2" is -# 0x's Settler-era id, which the mainnet book's "zeroex" substring does not catch. -[venues.metamask.solver_aliases] -"0xv2" = "0x" -kyber = "kyberswap" -uniswap = "uniswap" -openocean = "openocean" # Rabby SwapProxy — same address as mainnet, and the only Rabby entry point: its other swaps go # through shared routers (the 0x Settler, 1inch, Uniswap, Fly) and are recognised by the fee leg -# (see rabby.rs). The current fee wallet took 1452 legs in the sample; the historical wallet +# in `[venue_fees]`. The current fee wallet took 1452 legs in the sample; the historical wallet # 0x39041f… took nothing. [venues.rabby] entry_points = ["0x02e5be68d46dac0b524905bff209cf47ee6db2a9"] -fee_collectors = ["0xcd6b980029e6e6e0733ac8ec3e02be9410d09799"] # Rainbow's own router ("Rainbow: Router", same address as mainnet). It takes its fee on the input -# side and keeps it in the router, so the fee is read from calldata (see rainbow.rs) and there is no -# collector. Live here: 105 outgoing token transfers in the sample. +# side and keeps it in the router, never transferring it out, so the cut stays inside `amount_in`. +# Live here: 105 outgoing token transfers in the sample. [venues.rainbow] entry_points = ["0x00000000009726632680fb29d3f7a9734e3010e2"] -fee_collectors = [] # Venues identified by the fee they take on a shared router. Each wallet's legs were traced to a # settlement contract, so the dust-spray gotcha does not apply — one unverified contract diff --git a/tools/hindsight/src/decoder/registry/unichain.toml b/tools/hindsight/src/decoder/registry/unichain.toml index 951dfdd39..7d22ad73c 100644 --- a/tools/hindsight/src/decoder/registry/unichain.toml +++ b/tools/hindsight/src/decoder/registry/unichain.toml @@ -2,8 +2,9 @@ # and registry.rs for what each section drives. Addresses gathered 2026-07-29. # # Accuracy note: a wrong or missing *solver* address only costs coverage (the swap fails to -# match), but a wrong *venue fee collector* produces wrong records (the fee stays inside the -# amounts). Every contract below was checked to be deployed on Unichain mainnet. +# match), but a wrong *venue fee wallet* produces wrong records: `[venue_fees]` drives both the +# venue label and the fee correction on the amounts. Every contract below was checked to be deployed +# on Unichain mainnet. # # Unichain is the thinnest of the chains Hindsight covers, and several things live elsewhere are # absent here, each checked rather than assumed (2026-07-29): @@ -56,6 +57,8 @@ batch_settlers = [] # where most of its swap flow settles. "0xef740bf23acae26f6492b10de645d6b98dc8eaf3" = "uniswap" "0x73855d06de49d0fe4a9c42636ba96c62da12ff9c" = "uniswap" +# Universal Router 2.1.1 (developers.uniswap.org supported-chains table) +"0xfdf682f51fe81aa4898f0ae2163d8a55c127fbc7" = "uniswap" # Tycho (PropellerHeads) routers on Unichain: V3 and the V2 deployment still in use # (tycho-execution config/router_addresses.json and the contract-addresses docs) "0x764bc67b1036b00bc91221e988261f971a1c7ce4" = "tycho" @@ -64,9 +67,9 @@ batch_settlers = [] # (docs.fly.trade/developers/deployments) "0x20f6ee51340adeed01a59b0e65cb3703f3dc860c" = "fly" -# Venues. Relay's Unichain contracts and fee collector are the same addresses as mainnet; its -# routers are the Cancun-EVM deployment. Traffic is thin (5 fee legs to 0xf70da978… in a 20k-block -# sample) but real. +# Venues. Relay's Unichain contracts are the same addresses as mainnet; its routers are the +# Cancun-EVM deployment. Traffic is thin (5 fee legs to 0xf70da978… in a 20k-block sample) but +# real. Relay is not in `[venue_fees]`, so its cut is not corrected out of the amounts. [venues.relay] entry_points = [ "0xf5042e6ffac5a625d4e7848e0b01373d8eb9e222", @@ -75,15 +78,13 @@ entry_points = [ "0xccc88a9d1b4ed6b0eaba998850414b24f1c315be", "0x58cc3e0aa6cd7bf795832a225179ec2d848ce3e7", ] -fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # Rabby SwapProxy — same address as mainnet. Verified on-chain: a decoded Uniswap-v4-routed swap # through the proxy paid its 0.25% fee to 0xcd6b9800… (mainnet fee wallet 1); the historical wallet # 0x39041f… is unused on Unichain. As on mainnet, only the proxy is an entry point — shared-router -# Rabby swaps are recognised by the fee leg (see rabby.rs), not by tx.to. +# Rabby swaps are labelled by the fee wallet in `[venue_fees]`, not by tx.to. [venues.rabby] entry_points = ["0x02e5be68d46dac0b524905bff209cf47ee6db2a9"] -fee_collectors = ["0xcd6b980029e6e6e0733ac8ec3e02be9410d09799"] # Venues identified by the fee they take on a shared router. Verified on-chain: a decoded swap # entered through 0x's AllowanceHolder and paid Robinhood's wallet, so the leg is a real fee and diff --git a/tools/hindsight/src/decoder/sandwich.rs b/tools/hindsight/src/decoder/sandwich.rs index 57e4cdfbb..2c0f19c85 100644 --- a/tools/hindsight/src/decoder/sandwich.rs +++ b/tools/hindsight/src/decoder/sandwich.rs @@ -255,7 +255,7 @@ mod tests { use super::*; use crate::decoder::{ test_utils::{addr, make_pool_log, make_transfer_log, receipt, tx_hash}, - AttributionSource, + AttributionSource, DecodeSource, DecodeTier, }; /// The victim fixture's output token, unless a test overrides it. @@ -275,15 +275,13 @@ mod tests { venue: "relay".into(), solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender, token_in: addr(59), token_out, amount_in: U256::from(1_000u64), amount_out: U256::from(2_000u64), - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, diff --git a/tools/hindsight/src/decoder/solvers/attribution.rs b/tools/hindsight/src/decoder/solvers/attribution.rs deleted file mode 100644 index 152f81424..000000000 --- a/tools/hindsight/src/decoder/solvers/attribution.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Which solver settled a matched transaction. -//! -//! One decision, taken here in full: the solver label on a record comes from the first evidence -//! tier that answers, most- to least-trusted (see `AttributionSource`). The tier is recorded -//! alongside the label so downstream analysis can weigh it — an embedded quote attached to a -//! `declared` attribution is solid; one attached to a `largest_call` guess is not. - -use alloy::{primitives::Address, rpc::types::trace::geth::CallFrame}; -use serde::Serialize; - -use crate::decoder::{registry::Registry, trace}; - -/// The evidence tier that produced a record's solver label, most- to least-trusted. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum AttributionSource { - /// The decode strategy read the solver from calldata (`MetaMask`'s `aggregatorId`). - Declared, - /// The entry point (`tx.to`) is itself a known solver router: the trade settled there. - EntryPoint, - /// A known solver router was called inside the trace (venue-wrapped entries). - TraceMatch, - /// No known router anywhere: best guess is the external call that moved the most native - /// value (an unknown router's address). - LargestCall, - /// Even the guess was indeterminate (e.g. a token→token trace where no call moves value). - /// The record is labeled with its entry point — typically the venue's name — flagging it - /// for registry expansion. - Fallback, -} - -/// A solver label and the evidence tier it came from. -pub(crate) struct Attribution { - pub solver: String, - pub source: AttributionSource, -} - -/// Attribute the solver that settled a matched transaction. -/// -/// `declared` is the strategy's own claim (from calldata), which outranks everything; the -/// remaining tiers read the trace, ending at the entry-point label as the honest "don't know". -pub(crate) fn attribute( - declared: Option, - root: &CallFrame, - entry_point: Address, - sender: Address, - registry: &Registry, -) -> Attribution { - if let Some(solver) = declared { - return Attribution { solver, source: AttributionSource::Declared }; - } - if registry.is_solver(entry_point) { - return Attribution { - solver: registry.label(entry_point), - source: AttributionSource::EntryPoint, - }; - } - if let Some(found) = trace::find_solver_frame(root, registry).and_then(|frame| frame.to) { - return Attribution { solver: registry.label(found), source: AttributionSource::TraceMatch }; - } - if let Some(guess) = trace::largest_external_call(root, entry_point, sender, registry) { - return Attribution { - solver: registry.label(guess), - source: AttributionSource::LargestCall, - }; - } - Attribution { solver: registry.label(entry_point), source: AttributionSource::Fallback } -} - -#[cfg(test)] -mod tests { - use alloy::primitives::address; - - use super::*; - use crate::decoder::test_utils::{addr, frame, PERMIT2}; - - #[test] - fn test_declared_solver_with_trace_candidate() { - // MetaMask declares its solver in calldata; even a known router in the trace must not - // override it. - let registry = Registry::ethereum(); - let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); - let mut root = frame("CALL", addr(1), addr(2), 0); - root.calls = vec![frame("CALL", addr(2), oneinch, 1000)]; - - let attribution = - attribute(Some("uniswap".to_string()), &root, addr(2), addr(1), ®istry); - assert_eq!(attribution.solver, "uniswap"); - assert_eq!(attribution.source, AttributionSource::Declared); - } - - #[test] - fn test_direct_swap_entry_point() { - let registry = Registry::ethereum(); - let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); - let root = frame("CALL", addr(1), oneinch, 0); - - let attribution = attribute(None, &root, oneinch, addr(1), ®istry); - assert_eq!(attribution.solver, "1inch"); - assert_eq!(attribution.source, AttributionSource::EntryPoint); - } - - #[test] - fn test_relay_internal_solver() { - // Mirrors the real Relay tx: the client router calls 0x's AllowanceHolder. - // root(relay) -> [ relay (self-call), 0x AllowanceHolder (the solver) ] - let registry = Registry::ethereum(); - let sender = addr(1); - let relay = address!("0xf5042e6ffac5a625d4e7848e0b01373d8eb9e222"); - let zerox = address!("0x0000000000001ff3684f28c67538d4d072c22734"); - - let mut root = frame("CALL", sender, relay, 0); - root.calls = vec![frame("CALL", relay, relay, 0), frame("CALL", relay, zerox, 1000)]; - - let attribution = attribute(None, &root, relay, sender, ®istry); - assert_eq!(attribution.solver, "0x"); - assert_eq!(attribution.source, AttributionSource::TraceMatch); - } - - #[test] - fn test_relay_tycho_router() { - // Real tx 0x8b461c…: Relay ApprovalProxy -> Relay router -> Tycho router. - // The settling solver is Tycho even though it sits two levels deep. - let registry = Registry::ethereum(); - let sender = addr(1); - let relay_proxy = address!("0xccc88a9d1b4ed6b0eaba998850414b24f1c315be"); - let relay_router = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); - let tycho = address!("0x1f8db310f32d48b6180ff902ec60c586128cef47"); - - let mut router_call = frame("CALL", relay_proxy, relay_router, 0); - router_call.calls = vec![frame("CALL", relay_router, tycho, 0)]; - let mut root = frame("CALL", sender, relay_proxy, 0); - root.calls = vec![router_call]; - - let attribution = attribute(None, &root, relay_proxy, sender, ®istry); - assert_eq!(attribution.solver, "tycho"); - assert_eq!(attribution.source, AttributionSource::TraceMatch); - } - - #[test] - fn test_unknown_solver_largest_external_call() { - // No known solver in the trace: pick the largest external call, - // skipping the client self-call and the refund back to the sender. - let registry = Registry::ethereum(); - let sender = addr(1); - let client = addr(2); - let unknown_router = addr(50); - - let mut root = frame("CALL", sender, client, 0); - root.calls = vec![ - frame("CALL", client, client, 0), // self-call, skipped - frame("CALL", client, sender, 9000), // refund to sender, skipped - frame("CALL", client, addr(51), 10), // small external call - frame("CALL", client, unknown_router, 5000), // largest external call - ]; - - let attribution = attribute(None, &root, client, sender, ®istry); - assert_eq!(attribution.solver, unknown_router.to_string()); - assert_eq!(attribution.source, AttributionSource::LargestCall); - } - - #[test] - fn test_attribution_zero_value_fallback() { - // Unknown solver, token->token swap: every child call moves zero value, so the guess - // would degenerate to the first child (the Permit2 token pull). The record is labeled - // with its entry point instead, marked as a fallback. - let registry = Registry::ethereum(); - let sender = addr(1); - let client = addr(2); - - let mut root = frame("CALL", sender, client, 0); - root.calls = vec![ - frame("CALL", client, PERMIT2, 0), // token pull - frame("CALL", client, addr(50), 0), // unknown solver, zero value - ]; - - let attribution = attribute(None, &root, client, sender, ®istry); - assert_eq!(attribution.solver, client.to_string()); - assert_eq!(attribution.source, AttributionSource::Fallback); - } - - #[test] - fn test_attribution_wrapped_native_frames() { - // ETH-input swap through an unknown router: the highest-value direct call is the - // WETH.deposit() wrapping the input. Infrastructure, not a solver — the guess must - // fall through to the real router call. - let registry = Registry::ethereum(); - let sender = addr(1); - let client = addr(2); - let solver = addr(50); - - let mut root = frame("CALL", sender, client, 0); - root.calls = vec![ - frame("CALL", client, registry.wrapped_native(), 9000), // wrap, skipped - frame("CALL", client, solver, 100), - ]; - - let attribution = attribute(None, &root, client, sender, ®istry); - assert_eq!(attribution.solver, solver.to_string()); - assert_eq!(attribution.source, AttributionSource::LargestCall); - } - - #[test] - fn test_attribution_permit2_frames() { - // Even when Permit2 is the highest-value direct call, it is infrastructure, not a solver. - let registry = Registry::ethereum(); - let sender = addr(1); - let client = addr(2); - let solver = addr(50); - - let mut root = frame("CALL", sender, client, 0); - root.calls = vec![frame("CALL", client, PERMIT2, 9000), frame("CALL", client, solver, 100)]; - - let attribution = attribute(None, &root, client, sender, ®istry); - assert_eq!(attribution.solver, solver.to_string()); - assert_eq!(attribution.source, AttributionSource::LargestCall); - } -} diff --git a/tools/hindsight/src/decoder/solvers/cow.rs b/tools/hindsight/src/decoder/solvers/cow.rs new file mode 100644 index 000000000..28ae3d111 --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/cow.rs @@ -0,0 +1,259 @@ +//! `CoW` Protocol settlement decoding. +//! +//! `CoW` settles signed orders in a batch: `tx.to` is the settlement contract and `tx.from` is the +//! solver, so the trade is an order owner's — read here from the `GPv2` `Trade` event the +//! settlement emits per order. The event gives the exact executed amounts and the owner directly: +//! declared data, so these records carry `decode: "declared"` like calldata decodes. +//! +//! One trade is produced per transaction, so only single-order settlements are decoded; a batch +//! settling several orders is declined to the netting fallback (which nets one swapper's flow). +//! `CoW`'s fee is taken from the sell token and backed out of the input so a re-solve compares +//! like-for-like — modern `CoW` records a zero on-chain fee (it is priced into the order). + +use alloy::{ + primitives::B256, + rpc::types::Log, + sol, + sol_types::{SolCall, SolEvent}, +}; + +use crate::decoder::{ + solvers::{normalize_native, DeclaredSwap, SolverDecoder, VenueTag}, + transfer_ledger::to_primitive_log, + veto::Veto, +}; + +sol! { + /// `GPv2` per-order settlement event. + event Trade( + address indexed owner, + address sellToken, + address buyToken, + uint256 sellAmount, + uint256 buyAmount, + uint256 feeAmount, + bytes orderUid + ); + + /// `GPv2Settlement.settle`, decoded only for the per-order `appData` hash — the frontend tag + /// (`appCode`) the event does not carry. The other fields are named to match the ABI so the + /// decode lines up; only `trades[].appData` is read. + struct SettleTrade { + uint256 sellTokenIndex; + uint256 buyTokenIndex; + address receiver; + uint256 sellAmount; + uint256 buyAmount; + uint32 validTo; + bytes32 appData; + uint256 feeAmount; + uint256 flags; + uint256 executedAmount; + bytes signature; + } + struct SettleInteraction { + address target; + uint256 value; + bytes callData; + } + function settle( + address[] tokens, + uint256[] clearingPrices, + SettleTrade[] trades, + SettleInteraction[][3] interactions + ); +} + +/// The settled order's `appData` hash, when the batch settles exactly one order — the same +/// single-order rule `settlement_trade` applies, since a multi-order batch has no single frontend +/// to attribute. A transaction that is not a `settle` call fails to decode and yields `None`. +fn venue_tag(input: &[u8]) -> Option { + let call = settleCall::abi_decode(input).ok()?; + let [trade] = call.trades.as_slice() else { + return None; + }; + Some(trade.appData) +} + +/// `CoW`'s settlement. +pub(crate) struct Cow; + +impl SolverDecoder for Cow { + /// The settled order's trade, read from `CoW`'s own `Trade` event: the executed amounts and + /// the owner, stated outright. The calldata is not read — a settlement's inner router frames + /// are order plumbing, not the trade. + fn declared(&self, _input: &[u8], logs: &[Log]) -> Result, Veto> { + Ok(settlement_trade(logs)) + } + + /// The `appData` hash the settled order committed — the frontend tag (`appCode`) the `Trade` + /// event does not carry, so it is read from the settle calldata instead. + fn venue_fingerprint(&self, input: &[u8], _logs: &[Log]) -> Option { + venue_tag(input).map(VenueTag::AppData) + } +} + +/// The single settled order's trade, read from the `GPv2` `Trade` event. `None` when no `Trade` +/// event is present, or the batch settles more than one order (left to the netting fallback). +fn settlement_trade(logs: &[Log]) -> Option { + let mut trades = logs + .iter() + .filter(|log| log.topics().first() == Some(&Trade::SIGNATURE_HASH)); + let first = trades.next()?; + if trades.next().is_some() { + return None; + } + let trade = Trade::decode_log(&to_primitive_log(first)).ok()?; + + // CoW's fee is taken from the sell token, so the amount that actually reached the market is + // the executed sell minus the fee. + let amount_in = trade + .sellAmount + .saturating_sub(trade.feeAmount); + Some(DeclaredSwap::from_event( + trade.owner, + normalize_native(trade.sellToken), + amount_in, + normalize_native(trade.buyToken), + trade.buyAmount, + )) +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{address, b256, Address, Bytes, U256}; + + use super::*; + use crate::decoder::{solvers::NATIVE_TOKEN_SENTINEL, test_utils::addr}; + + /// The Ethereum `CoW` settlement contract (a registered batch settler). + const COW_SETTLEMENT: Address = address!("0x9008d19f58aabd9ed0d60971565aa8510560ab41"); + + fn trade_log( + settler: Address, + owner: Address, + sell_token: Address, + buy_token: Address, + sell_amount: u64, + buy_amount: u64, + fee_amount: u64, + ) -> Log { + let event = Trade { + owner, + sellToken: sell_token, + buyToken: buy_token, + sellAmount: U256::from(sell_amount), + buyAmount: U256::from(buy_amount), + feeAmount: U256::from(fee_amount), + orderUid: Bytes::new(), + }; + let data = event.encode_log_data(); + let primitive = alloy::primitives::Log::new_unchecked( + settler, + data.topics().to_vec(), + data.data.clone(), + ); + Log { inner: primitive, ..Default::default() } + } + + fn decode(logs: &[Log]) -> Option { + settlement_trade(logs) + } + + #[test] + fn test_single_order_reads_the_trade_event() { + let owner = addr(100); + let sell = addr(10); + let buy = addr(11); + // Fee is taken from the sell token: 10 of the 1000 sold is the fee, 990 reached the market. + let flow = decode(&[trade_log(COW_SETTLEMENT, owner, sell, buy, 1000, 2000, 10)]).unwrap(); + assert_eq!( + flow, + DeclaredSwap::from_event(owner, sell, U256::from(990), buy, U256::from(2000)) + ); + } + + #[test] + fn test_native_eth_sentinel_normalized() { + let flow = decode(&[trade_log( + COW_SETTLEMENT, + addr(100), + addr(10), + NATIVE_TOKEN_SENTINEL, + 1000, + 5, + 0, + )]) + .unwrap(); + assert_eq!(flow.token_out, Address::ZERO); + } + + #[test] + fn test_multi_order_batch_declined() { + // Two orders in one settlement: one trade per transaction, so this is left to the + // netting fallback. + let logs = vec![ + trade_log(COW_SETTLEMENT, addr(100), addr(10), addr(11), 1000, 2000, 0), + trade_log(COW_SETTLEMENT, addr(101), addr(11), addr(10), 2000, 1000, 0), + ]; + assert!(decode(&logs).is_none()); + } + + #[test] + fn test_no_trade_event_declined() { + // A non-CoW intent fill (no Trade event) is declined so the netting fallback runs instead. + assert!(decode(&[]).is_none()); + } + + fn settle_trade(app_data: B256) -> SettleTrade { + SettleTrade { + sellTokenIndex: U256::ZERO, + buyTokenIndex: U256::ZERO, + receiver: Address::ZERO, + sellAmount: U256::ZERO, + buyAmount: U256::ZERO, + validTo: 0, + appData: app_data, + feeAmount: U256::ZERO, + flags: U256::ZERO, + executedAmount: U256::ZERO, + signature: Bytes::new(), + } + } + + fn settle_calldata(trades: Vec) -> Vec { + settleCall { + tokens: vec![], + clearingPrices: vec![], + trades, + interactions: Default::default(), + } + .abi_encode() + } + + #[test] + fn test_single_order_reads_app_data() { + let app = b256!("0xf249b3db926aa5b5a1b18f3fec86b9cc99b9a8a99ad7e8034242d2838ae97422"); + assert_eq!( + Cow.venue_fingerprint(&settle_calldata(vec![settle_trade(app)]), &[]), + Some(VenueTag::AppData(app)) + ); + } + + #[test] + fn test_multi_order_batch_has_no_single_app_data() { + let trades = vec![settle_trade(B256::ZERO), settle_trade(B256::ZERO)]; + assert!(Cow + .venue_fingerprint(&settle_calldata(trades), &[]) + .is_none()); + } + + #[test] + fn test_calldata_that_is_not_a_settlement_has_no_app_data() { + // The settle decode is its own guard: another router's calldata does not parse, so a + // transaction CoW did not shape yields no tag even though CoW's decoder was asked. + assert!(Cow + .venue_fingerprint(&alloy::hex::decode("0xdeadbeef").unwrap(), &[]) + .is_none()); + } +} diff --git a/tools/hindsight/src/decoder/solvers/fixtures/oneinch_input.txt b/tools/hindsight/src/decoder/solvers/fixtures/oneinch_input.txt new file mode 100644 index 000000000..d5531586f --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/fixtures/oneinch_input.txt @@ -0,0 +1 @@ +0x07ed2379000000000000000000000000111116053f09d34a7eae8102887004445176ca1100000000000000000000000073d7c860998ca3c01ce8c808f5577d94d545d1b4000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000111116053f09d34a7eae8102887004445176ca1100000000000000000000000059e4d2324bf6bfc8f568125b8a03266c7d4a4726000000000000000000000000000000000000000000000acd874a77eaa61c4000000000000000000000000000000000000000000000000000152e0901b081819100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000022800000000000000000000000000000000000000020a0001f400019400004e00a0744c8c0973d7c860998ca3c01ce8c808f5577d94d545d1b4cd6b980029e6e6e0733ac8ec3e02be9410d09799000000000000000000000000000000000000000000000006e9f02fa8e818640000a0c9e75c4800000000000000000000000000000050ffff001400000000000000000000000000000000000000000000010e0000b700007b0c2073d7c860998ca3c01ce8c808f5577d94d545d1b4c09bf2b1bc8725903c509e8caeef9190857215a86ae4071198002dc6c0c09bf2b1bc8725903c509e8caeef9190857215a8000000000000000000000000000000000000000000000000043d2f817dd459b573d7c860998ca3c01ce8c808f5577d94d545d1b44101c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200042e1a7d4d000000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000010f0d89abfd879b948c9503300000000000000000000000000000000000000000073d7c860998ca3c01ce8c808f5577d94d545d1b4001b58000032000000a0cd211e1eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000001564cd9f71a46e310000000000000000000030627a4934991e647f04ceedfca4f7dc3d93d8b67b6ed4910120888fc061111111125421ca6dc452d289314280a0f8842a650000000000000000000000000000000000000000000000002a6f45f2 diff --git a/tools/hindsight/src/decoder/solvers/fixtures/paraswap_input.txt b/tools/hindsight/src/decoder/solvers/fixtures/paraswap_input.txt new file mode 100644 index 000000000..ff73f2a1a --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/fixtures/paraswap_input.txt @@ -0,0 +1 @@ +0xe3ead59e000000000000000000000000082738d007001080a00099a000004f3006152085000000000000000000000000ce6170ea245dc8d1f275a710a062b70f125f0110000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000014dbbba1000000000000000000000000000000000000000000000000029983106571aecc6000000000000000000000000000000000000000000000000299941a54ea3b24c65d6302b4dfb4c66ad5628e71d4504ca0000000000000000000000000188c9e8000000000000000000000000fdff0b569f14af593d446e51b3e42f502124ac85fdff0b569f14af593d446e51b3e42f502124ac859000000000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000880000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000008800000000000000000000000000000068000000000000001200000000000002648e592427a0aece92de3edee1f18e0157c0586156400000160008401060000000b00000000000000000000000000000000000000000000000000000000c04b8d59000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000082738d007001080a00099a000004f3006152085000000000000000000000000000000000000000000000000000000006a8628ed00000000000000000000000000000000000000000000000000000001470f032400000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000042ce6170ea245dc8d1f275a710a062b70f125f0110000bb88292bb45bf1ee4d140127049757c2e0ff06317ed000064a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004e00484018f000a000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000048000000000000000000000000000000160000000000000012000000000000002641b81d678ffb9c0263b24a97847620c99d213eb1400000140008400000000000300000000000000000000000000000000000000000000000000000000c04b8d59000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000006a000f20005980200259b80c5102003040001068000000000000000000000000000000000000000000000000000000006a8628ed000000000000000000000000000000000000000000000000000000001420a80e0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000064c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000000000000160000000000000012000000000000018b7e592427a0aece92de3edee1f18e0157c0586156400000140008400000000000300000000000000000000000000000000000000000000000000000000c04b8d59000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000006a000f20005980200259b80c5102003040001068000000000000000000000000000000000000000000000000000000006a8628ed00000000000000000000000000000000000000000000000000000000d0155ff60000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb480001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000016000000000000001200000000000000bf5e592427a0aece92de3edee1f18e0157c0586156400000140008400000000000300000000000000000000000000000000000000000000000000000000c04b8d59000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000006a000f20005980200259b80c5102003040001068000000000000000000000000000000000000000000000000000000006a8628ed0000000000000000000000000000000000000000000000000000000064abb3a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000064c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000000000000180000000000000012000000000000000c8e592427a0aece92de3edee1f18e0157c0586156400000160008400000000000300000000000000000000000000000000000000000000000000000000c04b8d59000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000006a000f20005980200259b80c5102003040001068000000000000000000000000000000000000000000000000000000006a8628ed0000000000000000000000000000000000000000000000000000000006acb6ec00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000042ce6170ea245dc8d1f275a710a062b70f125f0110000bb8dac17f958d2ee523a2206206994597c13d831ec7000064c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000 diff --git a/tools/hindsight/src/decoder/solvers/fixtures/uniswap_v4_multi_hop_211_input.txt b/tools/hindsight/src/decoder/solvers/fixtures/uniswap_v4_multi_hop_211_input.txt new file mode 100644 index 000000000..b522205ab --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/fixtures/uniswap_v4_multi_hop_211_input.txt @@ -0,0 +1 @@ +0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006a7cf5d400000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000000000000000000000000004e72fbdd7738d4ba300000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000651e5ea84e2c8ef30ddbf62d716fb2bf37535ffe0000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000d5770936a6678353f1b17c342b29c4416b02908000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000109cbcbc6417777835a2d13e800bdfc000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060000000000000000000000000651e5ea84e2c8ef30ddbf62d716fb2bf37535ffe000000000000000000000000e80c23f23948342bd17a4cb42bccc1dea40247f90000000000000000000000000000000000000000000000000000000000000000756e697800000000000c diff --git a/tools/hindsight/src/decoder/solvers/fixtures/uniswap_v4_multi_hop_input.txt b/tools/hindsight/src/decoder/solvers/fixtures/uniswap_v4_multi_hop_input.txt new file mode 100644 index 000000000..56ebd58c0 --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/fixtures/uniswap_v4_multi_hop_input.txt @@ -0,0 +1 @@ +0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006a7cf0e300000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000580000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003070c0e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004600000000000000000000000000000000000000000000000000000000000000380000000000000000000000000000000000000000000000000000000000000002000000000000000000000000019640000000ba88d36206beb10d0e86011c8d08c00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000005da342a01c2513c78f10000000000000000000000000000000000000000000007b7ebedd7040d44cce0000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000180000000000000000000000000888888888887715fb9d9f84175af9e6cce46807e0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000088c948a03139d64c2195a0189da9ddbbf3328fc000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000006106a104f47104319e2da22a8cd2923df152829b9066f74a8c68c2a647f4c6ea97713a4c25d5a234fe335f6e0ca18848ac10d61e780d5b51d11d695250754d04011cffffffffffffffffffffffffffffffffffffffffffff2c3de43133125f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001223334444a7466fbf985b14e1f4edaf3883bca60000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000055f5894d7c4bc1f510f95445d18a7ebb2e0c8fc000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000061335b9339104a75a708405c45466922e7847a816f4d070a862ea815de6215ae5d6c57c90662cf4345fdda1547cbb28da2e86c192194439bc5763db457c60ceaec1bffffffffffffffffffffffffffffffffffffffffffffffffffffa50cef85c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000019640000000ba88d36206beb10d0e86011c8d08c0000000000000000000000000000000000000000000005da342a01c2513c78f100000000000000000000000000000000000000000000000000000000000000600000000000000000000000001223334444a7466fbf985b14e1f4edaf3883bca600000000000000000000000064180f4afccc9ae485daa8f4fc3626cad3f2634c000000000000000000000000000000000000000000000000000000000000000078 diff --git a/tools/hindsight/src/decoder/solvers/fly.rs b/tools/hindsight/src/decoder/solvers/fly.rs index 9512b7a01..6ad85d4ee 100644 --- a/tools/hindsight/src/decoder/solvers/fly.rs +++ b/tools/hindsight/src/decoder/solvers/fly.rs @@ -9,9 +9,15 @@ //! below (`InsufficientAmountOut()`, selector `0xe52970aa`); `expectedAmountOut` is Magpie's //! off-chain quote, usable as this solver's declared quote. -use alloy::primitives::{Address, U256}; +use alloy::{ + primitives::{Address, U256}, + rpc::types::Log, +}; -use crate::decoder::solvers::{SolverKnowledge, SwapIntent}; +use crate::decoder::{ + solvers::{DeclaredSwap, SolverDecoder}, + veto::Veto, +}; /// Selectors sharing `LibRouter`'s packed layout (`swapWithBackendSignature`, /// `swapWithMagpieSignature`, `swapWithUserSignature`, `swapWithoutSignature`, `swap`). @@ -40,6 +46,8 @@ struct SwapData { amount_out_min: U256, /// Magpie's off-chain quote. Can legitimately be absent (zero) in some calldata variants. expected_amount_out: U256, + /// The blob's `toAddress` field: the declared output recipient. + to_address: Address, } /// Read a 3-byte packed header at `header_offset`: a right-shift byte, then a 2-byte big-endian @@ -71,47 +79,60 @@ fn parse(input: &[u8]) -> Option { let amount_in = U256::from_be_slice(input.get(AMOUNT_IN_OFFSET..AMOUNT_IN_OFFSET + WORD_LEN)?); let amount_out_min = read_packed(input, AMOUNT_OUT_MIN_HEADER)?; let expected_amount_out = read_packed(input, EXPECTED_AMOUNT_OUT_HEADER)?; - Some(SwapData { from_asset, to_asset, amount_in, amount_out_min, expected_amount_out }) + // In practice Relay's own router, not the trader — Relay receives the output and forwards it + // — so the settled output is what this address *received*, never treated as the trader. + let to_address = + Address::from_slice(input.get(TO_ADDRESS_OFFSET..TO_ADDRESS_OFFSET + ADDRESS_LEN)?); + Some(SwapData { + from_asset, + to_asset, + amount_in, + amount_out_min, + expected_amount_out, + to_address, + }) } /// The Fly (Magpie) `DexAggregator` solver. pub(crate) struct Fly; -impl SolverKnowledge for Fly { +impl SolverDecoder for Fly { /// The trader's enforced swap terms: `amountOutMin` is the on-chain floor /// (`InsufficientAmountOut()` below it); `expectedAmountOut`, when present, is Magpie's /// declared quote and must not be stricter than the floor it is quoted against. `input` must /// carry Fly's packed layout (e.g. it is `None` when `input` is the outer Relay wrapper, not /// Fly's own frame); the hint is unused — Fly's fields sit at fixed offsets, not located by /// value. - fn swap_intent(&self, input: &[u8], _amount_in_hint: Option) -> Option { - let data = parse(input)?; + fn declared(&self, input: &[u8], _logs: &[Log]) -> Result, Veto> { + let Some(data) = parse(input) else { return Ok(None) }; if data.amount_in.is_zero() || data.amount_out_min.is_zero() { - return None; + return Ok(None); } if !data.expected_amount_out.is_zero() && data.amount_out_min > data.expected_amount_out { - return None; + return Ok(None); } - let intent = - SwapIntent::new(data.from_asset, data.to_asset, data.amount_in, data.amount_out_min); - Some(if data.expected_amount_out.is_zero() { + let intent = DeclaredSwap::from_calldata( + data.from_asset, + data.to_asset, + data.amount_in, + data.amount_out_min, + ) + .with_recipient(data.to_address); + Ok(Some(if data.expected_amount_out.is_zero() { intent } else { intent.with_quote(data.expected_amount_out, None) - }) - } - - /// The packed blob's `toAddress` field. In practice this is Relay's own router, not the - /// trader — Relay receives the output and forwards it — so callers must read the settled - /// output as what this address *received*, not treat it as the trader. - fn output_recipient(&self, input: &[u8]) -> Option
{ - has_fly_selector(input)?; - Some(Address::from_slice(input.get(TO_ADDRESS_OFFSET..TO_ADDRESS_OFFSET + ADDRESS_LEN)?)) + })) } } #[cfg(test)] mod tests { + /// The terms this solver reads from `input`, for tests that only care about the calldata path. + fn terms(input: &[u8]) -> Option { + Fly.declared(input, &[]).ok().flatten() + } + use alloy::primitives::address; use super::*; @@ -125,64 +146,44 @@ mod tests { } #[test] - fn test_real_fixture_swap_intent() { - let intent = Fly - .swap_intent(&real_input(), None) - .unwrap(); + fn test_real_fixture_declared_swap() { + let intent = terms(&real_input()).unwrap(); assert_eq!(intent.token_in, address!("0xfde4c96c8593536e31f229ea8f37b2ada2699bb2")); assert_eq!(intent.token_out, Address::ZERO); - assert_eq!(intent.amount_in, U256::from(19_694_643u64)); - assert_eq!(intent.min_amount_out, U256::from(10_217_898_321_149_381u64)); - assert_eq!(intent.quoted_amount_out(), U256::from(10_321_109_415_302_405u64)); + assert_eq!(intent.amount_in, Some(U256::from(19_694_643u64))); + assert_eq!(intent.min_amount_out, Some(U256::from(10_217_898_321_149_381u64))); + assert_eq!(intent.declared_quote, Some(U256::from(10_321_109_415_302_405u64))); } #[test] fn test_real_fixture_output_recipient() { - // Relay's own router — the delivery address, not the trader (see the method's doc). - let recipient = Fly - .output_recipient(&real_input()) - .unwrap(); - assert_eq!(recipient, address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f")); - } - - #[test] - fn test_output_recipient_wrong_selector() { - let mut input = real_input(); - input[0] = 0xff; - assert!(Fly.output_recipient(&input).is_none()); - } - - #[test] - fn test_output_recipient_truncated_input() { - let full = real_input(); - assert!(Fly - .output_recipient(&full[..80]) - .is_none()); + // Relay's own router — the delivery address, not the trader (see `parse`). + let intent = terms(&real_input()).unwrap(); + assert_eq!( + intent.output_recipient, + Some(address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f")) + ); } #[test] fn test_wrong_selector() { let mut input = real_input(); input[0] = 0xff; - assert!(Fly.swap_intent(&input, None).is_none()); + assert!(terms(&input).is_none()); } #[test] fn test_truncated_input() { let full = real_input(); // Cut before the fixed-offset fields are readable at all. - assert!(Fly - .swap_intent(&full[..100], None) - .is_none()); + assert!(terms(&full[..100]).is_none()); // Cut inside the packed-header pointer's target word. - assert!(Fly - .swap_intent(&full[..300], None) - .is_none()); + assert!(terms(&full[..300]).is_none()); } #[test] fn test_empty_input() { - assert!(Fly.swap_intent(&[], None).is_none()); + assert!(terms(&[]).is_none()); } #[test] @@ -190,7 +191,7 @@ mod tests { let mut input = real_input(); // Zero out the word the amountOutMin pointer resolves to (ptr 281 in this fixture). input[281..313].fill(0); - assert!(Fly.swap_intent(&input, None).is_none()); + assert!(terms(&input).is_none()); } #[test] @@ -203,6 +204,6 @@ mod tests { // fixture (ptrs 281 and 289), so filling the word instead would corrupt both readings // identically and leave them equal, not violate the check. input[AMOUNT_OUT_MIN_HEADER] = 0; - assert!(Fly.swap_intent(&input, None).is_none()); + assert!(terms(&input).is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/kyberswap.rs b/tools/hindsight/src/decoder/solvers/kyberswap.rs index a7cbddf75..cd432f40d 100644 --- a/tools/hindsight/src/decoder/solvers/kyberswap.rs +++ b/tools/hindsight/src/decoder/solvers/kyberswap.rs @@ -9,18 +9,12 @@ //! The same calldata names the integrator's fee recipients, which is how a frontend's cut out of //! the swap is recovered even though the frontend itself is not in the address book. -use alloy::{ - primitives::{Address, U256}, - sol, - sol_types::SolCall, -}; - -use crate::decoder::solvers::{SolverKnowledge, SwapIntent}; +use alloy::{primitives::U256, rpc::types::Log, sol, sol_types::SolCall}; -/// `KyberSwap` represents native ETH with this sentinel address rather than the zero address — -/// hindsight's convention — so it is normalized on the way out. -const KYBERSWAP_NATIVE: Address = - alloy::primitives::address!("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); +use crate::decoder::{ + solvers::{normalize_native, DeclaredSwap, SolverDecoder}, + veto::Veto, +}; sol! { /// `MetaAggregationRouterV2.swap`'s parameter shape, verified against a live reverted trade @@ -60,14 +54,6 @@ sol! { } /// Native ETH is `Address::ZERO` in hindsight's convention. -fn normalize_native(token: Address) -> Address { - if token == KYBERSWAP_NATIVE { - Address::ZERO - } else { - token - } -} - /// `KyberSwap`'s declared quote, scanned out of a swap frame's calldata: `AmountOut` and, when /// present, `Timestamp`. `Source` is not read — nothing downstream consumes it. /// @@ -100,7 +86,7 @@ fn declared_quote(input: &[u8]) -> Option<(U256, Option)> { /// The `KyberSwap` solver. pub(crate) struct Kyberswap; -impl SolverKnowledge for Kyberswap { +impl SolverDecoder for Kyberswap { /// Extract the trader's swap terms from a `swap` call's `SwapDescriptionV2`: /// `srcToken`/`dstToken` (native ETH normalized to `Address::ZERO`), `amount`, and the /// enforced floor `minReturnAmount` (the revert reads "Return amount is not enough" below @@ -108,84 +94,42 @@ impl SolverKnowledge for Kyberswap { /// word-aligned data. The hint is unused: `KyberSwap`'s fields are decoded by ABI position, not /// located by value. When the calldata also carries a `clientData` quote, it is attached; a /// missing or malformed one does not fail the intent. - fn swap_intent(&self, input: &[u8], _amount_in_hint: Option) -> Option { - let call = swapCall::abi_decode(input).ok()?; + fn declared(&self, input: &[u8], _logs: &[Log]) -> Result, Veto> { + let Ok(call) = swapCall::abi_decode(input) else { return Ok(None) }; let desc = call.execution.desc; if desc.amount.is_zero() || desc.minReturnAmount.is_zero() { - return None; + return Ok(None); } - let intent = SwapIntent::new( + // `dstReceiver` — KyberSwap passes this straight down to the inner pool, which pays it + // directly; the router itself never touches the output. + let intent = DeclaredSwap::from_calldata( normalize_native(desc.srcToken), normalize_native(desc.dstToken), desc.amount, desc.minReturnAmount, - ); - Some(match declared_quote(input) { + ) + .with_recipient(desc.dstReceiver); + Ok(Some(match declared_quote(input) { Some((amount_out, timestamp)) => intent.with_quote(amount_out, timestamp), None => intent, - }) - } - - /// `SwapDescriptionV2.dstReceiver` — `KyberSwap` passes this straight down to the inner pool, - /// which pays it directly; the router itself never touches the output. - fn output_recipient(&self, input: &[u8]) -> Option
{ - let call = swapCall::abi_decode(input).ok()?; - Some(call.execution.desc.dstReceiver) - } - - /// The integrator fee recipients Kyber's router is told to pay out of the swap. - /// - /// Read from the root call only. A wrapper that nests Kyber's calldata (Relay, `MetaMask`) - /// owns the flow and accounts its own fee, so a nested fee is that venue's to report. - fn fee_recipients(&self, input: &[u8]) -> Vec
{ - if let Ok(call) = swapCall::abi_decode(input) { - return call.execution.desc.feeReceivers; - } - if let Ok(call) = swapSimpleModeCall::abi_decode(input) { - return call.desc.feeReceivers; - } - Vec::new() + })) } } -/// A `MetaAggregationRouterV2.swap` call paying `fee_receivers`, for tests here and in -/// `solvers::tests`. Only the fee tier is meaningful; the rest is the minimum a decode needs. -/// -/// The layout is the one that decoded Base tx -/// 0x78c70ca665a6e5d15e2af5a5b497cb3c1eb1214000308f1f0e2eb8e7e8c63e69, whose declared receiver -/// 0x41ec04c3… is the address the trace shows taking 10% of the output. #[cfg(test)] -pub(crate) fn swap_calldata(fee_receivers: Vec) -> Vec { - use alloy::primitives::{Address, Bytes}; - swapCall { - execution: SwapExecutionParams { - callTarget: Address::ZERO, - approveTarget: Address::ZERO, - targetData: Bytes::default(), - desc: SwapDescriptionV2 { - srcToken: Address::ZERO, - dstToken: Address::ZERO, - srcReceivers: Vec::new(), - srcAmounts: Vec::new(), - feeReceivers: fee_receivers, - feeAmounts: vec![U256::from(1000)], - dstReceiver: Address::ZERO, - amount: U256::ZERO, - minReturnAmount: U256::ZERO, - flags: U256::from(704), - permit: Bytes::default(), - }, - clientData: Bytes::default(), - }, +mod tests { + /// The terms this solver reads from `input`, for tests that only care about the calldata path. + fn terms(input: &[u8]) -> Option { + Kyberswap + .declared(input, &[]) + .ok() + .flatten() } - .abi_encode() -} -#[cfg(test)] -mod tests { - use alloy::primitives::address; + use alloy::primitives::Address; use super::*; + use crate::decoder::solvers::NATIVE_TOKEN_SENTINEL; /// The real clientData blob of tx 0xf25ceafd… (the audited Relay+KyberSwap trade). const BLOB: &str = "{\"Source\":\"relay\",\"AmountInUSD\":\"70329.579441\",\ @@ -214,26 +158,6 @@ mod tests { assert!(declared_quote(&[]).is_none()); } - #[test] - fn test_fee_recipients_from_swap_calldata() { - let collector = address!("0x41ec04c311d54f787f9e6c83d3fc7036f572fea0"); - assert_eq!(Kyberswap.fee_recipients(&swap_calldata(vec![collector])), vec![collector]); - // A swap with no integrator fee names nobody. - assert!(Kyberswap - .fee_recipients(&swap_calldata(Vec::new())) - .is_empty()); - } - - #[test] - fn test_fee_recipients_from_foreign_calldata() { - // Kyber's blob nested in a wrapper's calldata is not a root Kyber call: that venue owns - // the fee. Neither is an empty input. - assert!(Kyberswap - .fee_recipients(&calldata_with(BLOB)) - .is_empty()); - assert!(Kyberswap.fee_recipients(&[]).is_empty()); - } - #[test] fn test_declared_quote_truncated_or_fieldless_blob() { // Truncated before the closing brace: no valid JSON object to parse. @@ -278,18 +202,16 @@ mod tests { } #[test] - fn test_swap_intent_round_trip() { + fn test_declared_round_trip() { let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); - let intent = Kyberswap - .swap_intent(&swap_calldata_with_terms(src, dst, 1_000_000, 990_000, ""), None) - .unwrap(); + let intent = terms(&swap_calldata_with_terms(src, dst, 1_000_000, 990_000, "")).unwrap(); assert_eq!(intent.token_in, src); assert_eq!(intent.token_out, dst); - assert_eq!(intent.amount_in, U256::from(1_000_000u64)); - assert_eq!(intent.min_amount_out, U256::from(990_000u64)); - // No clientData quote declared: the accessor falls back to the floor. - assert_eq!(intent.quoted_amount_out(), U256::from(990_000u64)); + assert_eq!(intent.amount_in, Some(U256::from(1_000_000u64))); + assert_eq!(intent.min_amount_out, Some(U256::from(990_000u64))); + // No clientData quote declared, so only the floor is recorded. + assert_eq!(intent.declared_quote, None); assert_eq!(intent.timestamp, None); } @@ -297,89 +219,66 @@ mod tests { fn test_output_recipient_round_trip() { let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); - let recipient = Kyberswap - .output_recipient(&swap_calldata_with_terms(src, dst, 1_000_000, 990_000, "")) - .unwrap(); - assert_eq!(recipient, Address::repeat_byte(0x77)); - } - - #[test] - fn test_output_recipient_garbage_input() { - assert!(Kyberswap - .output_recipient(&[]) - .is_none()); + let intent = terms(&swap_calldata_with_terms(src, dst, 1_000_000, 990_000, "")).unwrap(); + assert_eq!(intent.output_recipient, Some(Address::repeat_byte(0x77))); } #[test] - fn test_swap_intent_with_declared_quote() { + fn test_declared_with_declared_quote() { let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); - let intent = Kyberswap - .swap_intent(&swap_calldata_with_terms(src, dst, 1_000_000, 990_000, BLOB), None) - .unwrap(); - assert_eq!(intent.min_amount_out, U256::from(990_000u64)); - assert_eq!(intent.quoted_amount_out(), U256::from(70_400_409_935u64)); + let intent = terms(&swap_calldata_with_terms(src, dst, 1_000_000, 990_000, BLOB)).unwrap(); + assert_eq!(intent.min_amount_out, Some(U256::from(990_000u64))); + assert_eq!(intent.declared_quote, Some(U256::from(70_400_409_935u64))); assert_eq!(intent.timestamp, Some(1_783_421_726)); } #[test] - fn test_swap_intent_malformed_quote_does_not_fail_the_intent() { + fn test_declared_malformed_quote_does_not_fail_the_intent() { // clientData present but missing AmountOut: the ABI-decoded terms are still recovered, // the quote is just absent. let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); - let intent = Kyberswap - .swap_intent( - &swap_calldata_with_terms(src, dst, 1_000_000, 990_000, "{\"Source\":\"relay\"}"), - None, - ) - .unwrap(); - assert_eq!(intent.quoted_amount_out(), U256::from(990_000u64)); + let intent = terms(&swap_calldata_with_terms( + src, + dst, + 1_000_000, + 990_000, + "{\"Source\":\"relay\"}", + )) + .unwrap(); + assert_eq!(intent.declared_quote, None); assert_eq!(intent.timestamp, None); } #[test] - fn test_swap_intent_normalizes_native_eth() { - let intent = Kyberswap - .swap_intent( - &swap_calldata_with_terms( - KYBERSWAP_NATIVE, - Address::repeat_byte(0x22), - 1_000, - 900, - "", - ), - None, - ) - .unwrap(); + fn test_declared_normalizes_native_eth() { + let intent = terms(&swap_calldata_with_terms( + NATIVE_TOKEN_SENTINEL, + Address::repeat_byte(0x22), + 1_000, + 900, + "", + )) + .unwrap(); assert_eq!(intent.token_in, Address::ZERO); assert_eq!(intent.token_out, Address::repeat_byte(0x22)); } #[test] - fn test_swap_intent_zero_amounts_rejected() { + fn test_declared_zero_amounts_rejected() { let a = Address::repeat_byte(0x11); let b = Address::repeat_byte(0x22); - assert!(Kyberswap - .swap_intent(&swap_calldata_with_terms(a, b, 0, 900, ""), None) - .is_none()); - assert!(Kyberswap - .swap_intent(&swap_calldata_with_terms(a, b, 1_000, 0, ""), None) - .is_none()); + assert!(terms(&swap_calldata_with_terms(a, b, 0, 900, "")).is_none()); + assert!(terms(&swap_calldata_with_terms(a, b, 1_000, 0, "")).is_none()); } #[test] - fn test_swap_intent_garbage_input() { - assert!(Kyberswap - .swap_intent(&[], None) - .is_none()); - assert!(Kyberswap - .swap_intent(&[0xde, 0xad, 0xbe, 0xef], None) - .is_none()); + fn test_declared_garbage_input() { + assert!(terms(&[]).is_none()); + assert!(terms(&[0xde, 0xad, 0xbe, 0xef]).is_none()); // A well-formed but unrelated call (KyberSwap's own clientData blob calldata) must not // decode as a `swap` execution. - assert!(Kyberswap - .swap_intent(&calldata_with(BLOB), None) - .is_none()); + assert!(terms(&calldata_with(BLOB)).is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/lifi.rs b/tools/hindsight/src/decoder/solvers/lifi.rs index f5d0ead81..3c83945c7 100644 --- a/tools/hindsight/src/decoder/solvers/lifi.rs +++ b/tools/hindsight/src/decoder/solvers/lifi.rs @@ -1,46 +1,139 @@ -//! LiFi-specific matching rules. +//! `LiFi` Diamond decoding. //! -//! `LiFi`'s Diamond settles both same-chain swaps (decoded like any solver) and cross-chain -//! bridge orders, which must never decode as swaps. +//! The Diamond settles both same-chain swaps and cross-chain bridge orders, and says which it did +//! in its own events. A same-chain swap emits a generic-swap event carrying the whole trade — +//! both assets, both amounts, and the address the output was paid to — so nothing has to be +//! recovered from the ledger. A bridge order emits `LiFiTransferStarted` instead and is vetoed: +//! its real output lands on another chain. +//! +//! `toAmount` is the swap's gross output, before any cut the Diamond or its integrator takes. +//! Verified against 9 live Ethereum trades: every `fromAssetId`/`fromAmount` matched the settled +//! record exactly, 8 of 9 `toAmount` did too, and the ninth read 0.5% above the trader's receipt — +//! a fee taken out of the output, which the gross figure is meant to include so a re-solve +//! compares gross against gross. +//! +//! Native ETH is the zero address in these events, already hindsight's convention. -use alloy::{rpc::types::Log, sol, sol_types::SolEvent}; +use alloy::{ + primitives::{Address, U256}, + rpc::types::Log, + sol, + sol_types::SolEvent, +}; -use crate::decoder::{solvers::SolverKnowledge, transfer_ledger::to_primitive_log, veto::Veto}; +use crate::decoder::{ + solvers::{DeclaredSwap, SolverDecoder, VenueTag}, + transfer_ledger::to_primitive_log, + veto::Veto, +}; /// The `LiFi` solver. pub(crate) struct Lifi; -impl SolverKnowledge for Lifi { - /// Veto transactions that started a cross-chain bridge order. +impl SolverDecoder for Lifi { + /// The trade the Diamond's own generic-swap event states, or a veto when its events say the + /// order bridged to another chain instead. /// - /// A bridge deposit is not a same-chain swap: the real output lands on the destination - /// chain, and the trader's only same-chain receipt is a leftover refund. Netting that as a - /// swap pairs the full input with the refund — a trade that never happened, at an absurd rate. - fn solver_veto(&self, logs: &[Log]) -> Option { - logs.iter() - .any(|log| log.topics().first() == Some(&LiFiTransferStarted::SIGNATURE_HASH)) - .then_some(Veto::BridgeOrder) + /// Declines a transaction carrying more than one generic-swap event: that is several swaps in + /// one call, and no single event is the trade. + fn declared(&self, _input: &[u8], logs: &[Log]) -> Result, Veto> { + if let Some(veto) = bridge_order(logs) { + return Err(veto); + } + Ok(generic_swap(logs)) + } + + /// The integrator tag the Diamond's own swap event carries — the only fingerprint of a `LiFi` + /// frontend (Infinex, Robinhood's `LiFi` leg), which routes through the shared Diamond and so + /// has no entry point of its own. + fn venue_fingerprint(&self, _input: &[u8], logs: &[Log]) -> Option { + integrator(logs).map(VenueTag::Integrator) + } +} + +/// The single generic swap in a transaction's logs, from either facet. +/// +/// The current facet names the output recipient; the older one does not, so the caller falls back +/// to the transaction sender there. +fn generic_swap(logs: &[Log]) -> Option { + let mut swaps = logs.iter().filter_map(swap_event); + let first = swaps.next()?; + if swaps.next().is_some() { + return None; } + Some(first) +} + +/// One generic-swap event read as a settled trade, or `None` for any other log. +fn swap_event(log: &Log) -> Option { + let topic = log.topics().first()?; + if *topic == LiFiGenericSwapCompleted::SIGNATURE_HASH { + let event = LiFiGenericSwapCompleted::decode_log(&to_primitive_log(log)).ok()?; + return settled( + event.receiver, + event.fromAssetId, + event.fromAmount, + event.toAssetId, + event.toAmount, + ); + } + if *topic == LiFiSwappedGeneric::SIGNATURE_HASH { + let event = LiFiSwappedGeneric::decode_log(&to_primitive_log(log)).ok()?; + // This facet names no recipient; the caller anchors on the transaction sender. + return settled( + Address::ZERO, + event.fromAssetId, + event.fromAmount, + event.toAssetId, + event.toAmount, + ); + } + None +} - /// The integrator tag a same-chain `LiFi` swap declares, from either generic-swap event. This - /// is the only fingerprint of a `LiFi` frontend (Infinex, Robinhood's `LiFi` leg), which routes - /// through the shared Diamond. - fn integrator(&self, logs: &[Log]) -> Option { - logs.iter() - .find_map(|log| match log.topics().first() { - Some(topic) if *topic == LiFiGenericSwapCompleted::SIGNATURE_HASH => { - LiFiGenericSwapCompleted::decode_log(&to_primitive_log(log)) - .ok() - .map(|event| event.integrator.clone()) - } - Some(topic) if *topic == LiFiSwappedGeneric::SIGNATURE_HASH => { - LiFiSwappedGeneric::decode_log(&to_primitive_log(log)) - .ok() - .map(|event| event.integrator.clone()) - } - _ => None, - }) +/// A `DeclaredSwap` from one event's fields, declining the zero amounts a real swap cannot have. +fn settled( + receiver: Address, + token_in: Address, + amount_in: U256, + token_out: Address, + amount_out: U256, +) -> Option { + if amount_in.is_zero() || amount_out.is_zero() { + return None; } + let declared = DeclaredSwap::from_event(receiver, token_in, amount_in, token_out, amount_out); + // A zero receiver is the older facet's "not stated", not an address that received anything. + Some(if receiver.is_zero() { DeclaredSwap { tracked: None, ..declared } } else { declared }) +} + +/// Whether the transaction started a cross-chain bridge order. +/// +/// A bridge deposit is not a same-chain swap: the real output lands on the destination chain, and +/// the trader's only same-chain receipt is a leftover refund. Netting that as a swap pairs the +/// full input with the refund — a trade that never happened, at an absurd rate. +fn bridge_order(logs: &[Log]) -> Option { + logs.iter() + .any(|log| log.topics().first() == Some(&LiFiTransferStarted::SIGNATURE_HASH)) + .then_some(Veto::BridgeOrder) +} + +/// The integrator tag a same-chain `LiFi` swap declares, from either generic-swap event. +fn integrator(logs: &[Log]) -> Option { + logs.iter() + .find_map(|log| match log.topics().first() { + Some(topic) if *topic == LiFiGenericSwapCompleted::SIGNATURE_HASH => { + LiFiGenericSwapCompleted::decode_log(&to_primitive_log(log)) + .ok() + .map(|event| event.integrator.clone()) + } + Some(topic) if *topic == LiFiSwappedGeneric::SIGNATURE_HASH => { + LiFiSwappedGeneric::decode_log(&to_primitive_log(log)) + .ok() + .map(|event| event.integrator.clone()) + } + _ => None, + }) } sol! { @@ -77,13 +170,13 @@ sol! { #[cfg(test)] mod tests { - use alloy::primitives::{Bytes, Log as PrimitiveLog, U256}; + use alloy::primitives::{address, Bytes, Log as PrimitiveLog, B256, U256}; use super::*; use crate::decoder::test_utils::{addr, make_transfer_log}; #[test] - fn test_bridge_order() { + fn test_bridge_order_vetoes_the_transaction() { // The LiFi bridge shape (tx 0x72b71802…): 7.2 ETH in, swapped to USDT, 99.5% bridged out, // and only the leftover refunded to the trader — flagged by LiFiTransferStarted. let diamond = addr(70); @@ -93,10 +186,119 @@ mod tests { Bytes::default(), ); let logs = vec![Log { inner: primitive, ..Default::default() }]; - assert_eq!(Lifi.solver_veto(&logs), Some(Veto::BridgeOrder)); + assert_eq!(Lifi.declared(&[], &logs).err(), Some(Veto::BridgeOrder)); + // A same-chain LiFi swap is not vetoed; it carries no terms this decoder reads, so it + // decodes by netting. let swap_logs = vec![make_transfer_log(addr(10), addr(1), addr(2), U256::from(1000))]; - assert_eq!(Lifi.solver_veto(&swap_logs), None); + assert!(Lifi + .declared(&[], &swap_logs) + .is_ok_and(|declared| declared.is_none())); + } + + /// A `LiFiGenericSwapCompleted` log with the given trade. + fn generic_swap_log( + receiver: Address, + token_in: Address, + amount_in: u128, + token_out: Address, + amount_out: u128, + ) -> Log { + let event = LiFiGenericSwapCompleted { + transactionId: B256::ZERO, + integrator: "jumper.exchange".to_string(), + referrer: String::new(), + receiver, + fromAssetId: token_in, + toAssetId: token_out, + fromAmount: U256::from(amount_in), + toAmount: U256::from(amount_out), + }; + let data = event.encode_log_data(); + let primitive = + PrimitiveLog::new_unchecked(addr(70), data.topics().to_vec(), data.data.clone()); + Log { inner: primitive, ..Default::default() } + } + + fn declared_of(logs: &[Log]) -> Option { + Lifi.declared(&[], logs).ok().flatten() + } + + #[test] + fn test_generic_swap_reads_the_whole_trade() { + // Live tx 0xccbe500b…: native ETH in, USDT out, both amounts stated by the event. + let usdt = address!("0xdac17f958d2ee523a2206206994597c13d831ec7"); + let trader = addr(1); + let declared = declared_of(&[generic_swap_log( + trader, + Address::ZERO, + 326_595_334_876_135_158, + usdt, + 802_642_717, + )]) + .unwrap(); + assert_eq!(declared.tracked, Some(trader)); + assert_eq!(declared.token_in, Address::ZERO); + assert_eq!(declared.amount_in, Some(U256::from(326_595_334_876_135_158u64))); + assert_eq!(declared.token_out, usdt); + assert_eq!(declared.amount_out, Some(U256::from(802_642_717u64))); + // An event states the trade outright, so there is no floor to enforce. + assert_eq!(declared.min_amount_out, None); + } + + #[test] + fn test_older_facet_states_no_recipient() { + let usdc = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + let event = LiFiSwappedGeneric { + transactionId: B256::ZERO, + integrator: "infinex".to_string(), + referrer: String::new(), + fromAssetId: Address::ZERO, + toAssetId: usdc, + fromAmount: U256::from(1_000u64), + toAmount: U256::from(2_000u64), + }; + let data = event.encode_log_data(); + let primitive = + PrimitiveLog::new_unchecked(addr(70), data.topics().to_vec(), data.data.clone()); + let declared = declared_of(&[Log { inner: primitive, ..Default::default() }]).unwrap(); + // The facet names no receiver, so the caller anchors on the transaction sender. + assert_eq!(declared.tracked, None); + assert_eq!(declared.amount_out, Some(U256::from(2_000u64))); + } + + #[test] + fn test_several_generic_swaps_declined() { + // Two swaps in one call: no single event is the trade. + let usdc = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + let logs = vec![ + generic_swap_log(addr(1), Address::ZERO, 1_000, usdc, 2_000), + generic_swap_log(addr(2), usdc, 2_000, Address::ZERO, 1_000), + ]; + assert!(declared_of(&logs).is_none()); + } + + #[test] + fn test_zero_amounts_declined() { + let usdc = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + assert!(declared_of(&[generic_swap_log(addr(1), Address::ZERO, 0, usdc, 2_000)]).is_none()); + assert!(declared_of(&[generic_swap_log(addr(1), Address::ZERO, 1_000, usdc, 0)]).is_none()); + } + + #[test] + fn test_a_bridge_order_still_vetoes_over_a_swap_event() { + // Both events present: the bridge veto wins, because the real output left the chain. + let usdc = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + let primitive = PrimitiveLog::new_unchecked( + addr(70), + vec![LiFiTransferStarted::SIGNATURE_HASH], + Bytes::default(), + ); + let logs = vec![ + Log { inner: primitive, ..Default::default() }, + generic_swap_log(addr(1), Address::ZERO, 1_000, usdc, 2_000), + ]; + assert_eq!(Lifi.declared(&[], &logs).err(), Some(Veto::BridgeOrder)); } #[test] @@ -117,10 +319,10 @@ mod tests { let primitive = PrimitiveLog::new_unchecked(addr(70), data.topics().to_vec(), data.data.clone()); let logs = vec![Log { inner: primitive, ..Default::default() }]; - assert_eq!(Lifi.integrator(&logs).as_deref(), Some("infinex")); + assert_eq!(integrator(&logs).as_deref(), Some("infinex")); // A non-LiFi log carries no integrator. let other = vec![make_transfer_log(addr(10), addr(1), addr(2), U256::from(1))]; - assert_eq!(Lifi.integrator(&other), None); + assert_eq!(integrator(&other), None); } } diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index 1d2744914..cb0316068 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -1,61 +1,84 @@ -//! Solver-specific knowledge: the routers Fynd competes with. +//! Solver-specific decoders: the routers Fynd competes with. //! //! Solver addresses live in the address book's `[solvers]` section, and for most solvers that -//! line is all that is needed: matching, attribution, and gas isolation work from the address -//! alone. A solver whose transactions carry more information than that gets a module here with a -//! `SolverKnowledge` impl registered in `IMPLEMENTATIONS`: a swap intent recovered from calldata, -//! or a matching veto for order shapes that are not same-chain swaps. - -pub(crate) mod attribution; +//! line is all that is needed: matching, attribution, and metric labels work from the address +//! alone. A solver whose calldata or logs carry more than that gets a module here with a +//! `SolverDecoder` impl registered in `IMPLEMENTATIONS`: a `DeclaredSwap` read from its calldata +//! or its own event, or a veto for order shapes that are not same-chain swaps. The impl is joined +//! onto the registry's solver entry once, at address-book load (see `decoder_for`); at trade time +//! every lookup is by address through `Registry::solver`. + +pub(crate) mod cow; pub(crate) mod fly; pub(crate) mod kyberswap; pub(crate) mod lifi; +pub(crate) mod okx; +pub(crate) mod oneinch; pub(crate) mod paraswap; +pub(crate) mod uniswap; pub(crate) mod zeroex; -use std::collections::HashSet; - use alloy::{ - primitives::{Address, U256}, + primitives::{Address, B256, U256}, rpc::types::Log, }; -use crate::decoder::{registry::Registry, transfer_ledger::TransferLedger, veto::Veto}; +use crate::decoder::veto::Veto; -/// A trader's swap terms recovered from a solver frame's own calldata: what the trade moved, the -/// floor the trader would accept, and — when the calldata declares one — the solver's own -/// off-chain quote. +/// The trade a solver's own data states: always the two tokens and one of the two amounts, plus +/// whatever else its source happens to carry. +/// +/// One shape for every solver, whether it was read from calldata or from an event, because the +/// caller does not care which — it cares which fields arrived. A field is `None` when the source +/// could not carry it, so the absence is the instruction: /// -/// `token_in`/`token_out`/`amount_in`/`min_amount_out` are the on-chain enforced terms of the -/// swap itself, recovered so a reverted swap can still be judged against its floor, since a -/// revert emits no logs to net a settled amount from. The declared quote is different: it is the -/// number the venue compared against at decision time — what the solver's API promised — as -/// opposed to the settled amount, which is what execution delivered. It is self-reported and not -/// every solver declares one, so it is read through [`SwapIntent::quoted_amount_out`] (falls back -/// to the floor) or [`SwapIntent::declared_quote`] (the raw value, for callers that must tell a -/// real quote from the fallback). -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] -pub(crate) struct SwapIntent { +/// - `amount_out` absent means the source stated no output (exact-input calldata does this), so the +/// caller recovers it from `output_recipient`'s receipt in the transfer ledger. +/// - `amount_in` absent means the source fixed the output and only bounded the input (exact-output +/// calldata), so the caller recovers it from the payer's net payment. +/// - `tracked` absent means the source did not name the trader, so the caller falls back to the +/// transaction sender. +/// +/// One of the two amounts is always present: calldata fixes one side and bounds the other, and an +/// event states both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DeclaredSwap { /// `Address::ZERO` for native ETH. pub token_in: Address, /// `Address::ZERO` for native ETH. pub token_out: Address, - pub amount_in: U256, - /// The trader's on-chain enforced floor for `token_out` — the swap reverts below it. - pub min_amount_out: U256, - /// The solver's declared off-chain quote, when its calldata carries one. Private: the - /// ABI-decoded fields above are hard facts, this one is self-reported, so it is read only - /// through the accessors, never assumed present. - quoted_amount_out: Option, - /// Unix timestamp of the declared quote, when present. Only `KyberSwap`'s `clientData` - /// exposes one. + /// The amount spent, when the source stated it. `None` for exact-output calldata, which states + /// only `max_amount_in`. + pub amount_in: Option, + /// The settled output, when the source stated it outright (an event, or exact-output + /// calldata). `None` for exact-input calldata, which states only `min_amount_out`. + pub amount_out: Option, + /// The trader, when the source named them (an event's owner or sender). + pub tracked: Option
, + /// The trader's on-chain enforced floor for `token_out` — the swap reverts below it. Recorded + /// so a reverted swap can still be judged against its floor, since a revert emits no logs to + /// net a settled amount from. + pub min_amount_out: Option, + /// The trader's on-chain enforced ceiling for `token_in`, on an exact-output swap — the swap + /// reverts above it. Bounds the recovered `amount_in`. + pub max_amount_in: Option, + /// Who the output is paid to, when the calldata names them — whose receipt `amount_out` is + /// recovered from when the source stated none. + pub output_recipient: Option
, + /// The solver's own off-chain quote: the number the venue compared against at decision time, + /// as opposed to `amount_out`, which is what execution delivered. Self-reported, and not + /// every solver declares one. + pub declared_quote: Option, + /// Unix timestamp of `declared_quote`. Only `KyberSwap`'s `clientData` exposes one. pub timestamp: Option, } -impl SwapIntent { - /// A swap intent with just the ABI-enforced terms: token in/out, amount in, and the on-chain - /// floor. No declared quote or timestamp — attach one with [`SwapIntent::with_quote`]. - pub(crate) fn new( +impl DeclaredSwap { + /// A swap read from an **exact-input** solver call: the amount spent, and the floor the call + /// enforces on the output. No settled output — this calldata never carries one — so the caller + /// recovers it from the recipient's receipt. Add the recipient with + /// [`DeclaredSwap::with_recipient`] and an off-chain quote with [`DeclaredSwap::with_quote`]. + pub(crate) fn from_calldata( token_in: Address, token_out: Address, amount_in: U256, @@ -64,189 +87,168 @@ impl SwapIntent { Self { token_in, token_out, - amount_in, - min_amount_out, - quoted_amount_out: None, + amount_in: Some(amount_in), + amount_out: None, + tracked: None, + min_amount_out: Some(min_amount_out), + max_amount_in: None, + output_recipient: None, + declared_quote: None, timestamp: None, } } - /// Attach the solver's declared off-chain quote and, when known, its timestamp. - pub(crate) fn with_quote(mut self, quoted_amount_out: U256, timestamp: Option) -> Self { - self.quoted_amount_out = Some(quoted_amount_out); - self.timestamp = timestamp; - self + /// A swap read from an **exact-output** solver call: the output is fixed, so it is the settled + /// amount, and the input is only bounded by `max_amount_in`. The caller recovers the amount + /// actually spent from the payer's net payment. + pub(crate) fn from_calldata_exact_out( + token_in: Address, + token_out: Address, + amount_out: U256, + max_amount_in: U256, + ) -> Self { + Self { + token_in, + token_out, + amount_in: None, + amount_out: Some(amount_out), + tracked: None, + min_amount_out: None, + max_amount_in: Some(max_amount_in), + output_recipient: None, + declared_quote: None, + timestamp: None, + } } - /// The best available "what was promised": the solver's declared quote, or — when absent — - /// the enforced floor. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "only called from tests in this PR; its production caller is the \ - reverted-swap path in the stacked follow-up PR" - ) - )] - pub(crate) fn quoted_amount_out(&self) -> U256 { - self.quoted_amount_out - .unwrap_or(self.min_amount_out) + /// A swap read from a solver's **event**: the trade it already executed, both amounts and the + /// trader stated outright. Nothing is left to recover. No floor or ceiling — an event reports + /// what happened, not what was required. + pub(crate) fn from_event( + tracked: Address, + token_in: Address, + amount_in: U256, + token_out: Address, + amount_out: U256, + ) -> Self { + Self { + token_in, + token_out, + amount_in: Some(amount_in), + amount_out: Some(amount_out), + tracked: Some(tracked), + min_amount_out: None, + max_amount_in: None, + output_recipient: None, + declared_quote: None, + timestamp: None, + } } - /// The raw declared quote, `None` when the calldata carried none. Distinct from - /// [`SwapIntent::quoted_amount_out`], which falls back to the floor — analysts need to tell - /// a real quote from the fallback. - pub(crate) fn declared_quote(&self) -> Option { - self.quoted_amount_out + /// Attach the output recipient the same calldata declares. + pub(crate) fn with_recipient(mut self, output_recipient: Address) -> Self { + self.output_recipient = Some(output_recipient); + self } - /// Drop the declared quote, keeping the ABI-enforced terms. Used when the settled amount - /// shows the quote was self-reported garbage (see [`plausible_quote`]) — the ABI fields stay - /// trustworthy either way. - pub(crate) fn clear_quote(&mut self) { - self.quoted_amount_out = None; + /// Attach the solver's declared off-chain quote and, when known, its timestamp. + pub(crate) fn with_quote(mut self, declared_quote: U256, timestamp: Option) -> Self { + self.declared_quote = Some(declared_quote); + self.timestamp = timestamp; + self } } -/// Solver-specific knowledge beyond the address-book entry. -/// -/// Every method has a default meaning "this solver has nothing to add", so a solver only -/// implements the capabilities it has; most solvers need no code at all. -pub(crate) trait SolverKnowledge: Send + Sync { - /// The swap terms encoded in the solver frame's own calldata, when this solver's calldata - /// carries them plainly enough to recover without netting a settled amount. Dispatched with - /// the solver frame's input (found via `trace::find_solver_frame`/the reverted-tolerant - /// variant), not the root transaction's — a packed calldata layout (Fly) uses offsets valid - /// only in its own frame. - /// - /// `amount_in_hint` is the decoded flow's input amount, when one is known — absent for a - /// reverted trade, which has no netted flow to draw it from. Some extractors (`ParaSwap`) need - /// it to locate fields by value rather than by ABI offset. - fn swap_intent(&self, _input: &[u8], _amount_in_hint: Option) -> Option { - None +/// The address the ecosystem uses in calldata and events to mean native ETH, where hindsight uses +/// `Address::ZERO`. Not a per-solver fact: every solver that names native ETH names this address, +/// on every chain — so the name says nothing about a solver or about ETH specifically. +pub(crate) const NATIVE_TOKEN_SENTINEL: Address = + alloy::primitives::address!("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + +/// A token address on hindsight's convention: the native sentinel becomes `Address::ZERO`, and +/// every other address passes through. +pub(crate) fn normalize_native(token: Address) -> Address { + if token == NATIVE_TOKEN_SENTINEL { + Address::ZERO + } else { + token } +} - /// The address this solver's calldata declares as the output recipient, when it carries one - /// plainly enough to recover — how a calldata-primary decode learns whose receipt to read the - /// settled amount from, since calldata alone never carries a settled amount. Dispatched with - /// the same solver-frame input as `swap_intent`. `None` when the calldata carries no such - /// field (most solvers deliver to the caller implicitly) or it did not parse. - fn output_recipient(&self, _input: &[u8]) -> Option
{ - None - } +/// A venue fingerprint the settling solver's own data carries. Which venue it names is the +/// address book's business (`[venue_integrators]`, `[venue_appdata]`); a solver only reports the +/// raw tag it found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum VenueTag { + /// A frontend string the solver records in its own swap event, for venues that route through + /// a shared router and have no entry point of their own. + Integrator(String), + /// An order's committed `appData` hash, for a batch settler whose orders name their frontend. + AppData(B256), +} - /// The veto this solver's logs place on a matched transaction that is not decodable as a - /// swap. Checked at match time — before attribution names the solver, and before the - /// transaction costs a trace. - fn solver_veto(&self, _logs: &[Log]) -> Option { - None +/// One solver's decoder: what the solver's own calldata and logs say about a trade. +/// +/// Both methods are defaulted, so a solver only writes code when its transactions expose +/// something; most solvers need neither. A solver's veto is not a third method — it rides on +/// `declared`'s `Err`, because a veto is a statement about the same read. +/// +/// Whether the read came from calldata or from an event is not recorded on the trait: the caller +/// only needs to know which fields arrived, which the `Option`s on [`DeclaredSwap`] already say. +pub(crate) trait SolverDecoder: Send + Sync { + /// What this solver's own data says about the transaction: `Ok(None)` when it says nothing + /// this solver can read, `Err(veto)` when it says the transaction is not a swap at all and + /// must not be decoded by any means (`LiFi`'s cross-chain bridge orders). + /// + /// `input` is the solver frame's calldata (found via `trace::find_solver_frame`), not the root + /// transaction's: a packed layout (Fly) uses offsets valid only in its own frame. `logs` is the + /// whole receipt's logs, for a solver that states its trade in an event instead. Every solver + /// reads one or the other; the parameter it does not use is ignored. + fn declared(&self, _input: &[u8], _logs: &[Log]) -> Result, Veto> { + Ok(None) } - /// The order-flow integrator tag this solver records in its logs, when it exposes one. A - /// solver that fronts other apps (`LiFi`'s Diamond) carries the frontend's integrator string in - /// its swap event; venue attribution maps that tag to a venue (see - /// `crate::decoder::venue_attribution`). - fn integrator(&self, _logs: &[Log]) -> Option { + /// The venue fingerprint this solver's own data carries, when it carries one: the frontend + /// that built the order, which the entry point cannot name because the flow shares a router. + /// + /// `input` is the **root** transaction's calldata, not the solver frame's: a fingerprint sits + /// in the order the trader signed, which is what the outermost call carries. `logs` is the + /// whole receipt's logs. Decoding either is the solver's own guard — a transaction this + /// solver did not shape fails to parse and returns `None`. + fn venue_fingerprint(&self, _input: &[u8], _logs: &[Log]) -> Option { None } - - /// The fee recipients this solver's calldata names, for routers that let an integrator take a - /// cut of the swap. Only who is paid — `declared_output_fee` reads how much off the ledger. - fn fee_recipients(&self, _input: &[u8]) -> Vec
{ - Vec::new() - } } -/// The solvers with a `SolverKnowledge` implementation, by address-book name. A solver absent -/// here needs none — its address-book entry alone is complete. -const IMPLEMENTATIONS: &[(&str, &'static dyn SolverKnowledge)] = &[ +/// The solvers with a `SolverDecoder` implementation, by address-book name. A solver absent +/// here needs none — its address-book entry alone is complete. Consulted once, when the address +/// book loads (see `decoder_for`); everything after that calls the trait through the registry +/// entry. +const IMPLEMENTATIONS: &[(&str, &'static dyn SolverDecoder)] = &[ + ("cow", &cow::Cow), ("fly", &fly::Fly), ("kyberswap", &kyberswap::Kyberswap), ("lifi", &lifi::Lifi), + ("1inch", &oneinch::OneInch), + ("okx", &okx::Okx), ("paraswap", ¶swap::Paraswap), + ("uniswap", &uniswap::Uniswap), ("0x", &zeroex::ZeroEx), ]; -/// The veto a solver places on a matched transaction that must be skipped instead of decoded, -/// if any. -/// -/// Some solver routers also settle orders that are not same-chain swaps; decoding those would -/// record trades that never happened. A solver's veto is consulted only when that solver is -/// part of the transaction — as its entry point or as a log emitter — so a veto can never -/// affect another solver's trades. -pub(crate) fn solver_veto(logs: &[Log], entry_point: Address, registry: &Registry) -> Option { - for (name, knowledge) in IMPLEMENTATIONS { - let present = registry.solver_name(entry_point) == Some(name) || - logs.iter() - .any(|log| registry.solver_name(log.address()) == Some(name)); - if present { - if let Some(veto) = knowledge.solver_veto(logs) { - return Some(veto); - } - } - } - None -} - -/// The order-flow integrator tag declared in a transaction's logs, from whichever solver records -/// one. Only a solver that fronts other apps (`LiFi`) returns a tag; the rest default to `None`, so -/// the first hit is the answer. -pub(crate) fn integrator(logs: &[Log]) -> Option { - IMPLEMENTATIONS - .iter() - .find_map(|(_, knowledge)| knowledge.integrator(logs)) -} - -/// The swap terms encoded in the solver frame's own calldata, dispatched on the attributed -/// solver so a lookalike blob from another router cannot masquerade as an intent. -pub(crate) fn swap_intent( - solver: &str, - input: &[u8], - amount_in_hint: Option, -) -> Option { - let (_, knowledge) = IMPLEMENTATIONS - .iter() - .find(|(name, _)| *name == solver)?; - knowledge.swap_intent(input, amount_in_hint) -} +/// A solver with no `SolverDecoder` implementation: every method keeps its "nothing to add" +/// default, so callers hold one handle type and never branch on whether a solver has code. +struct NoDecoder; -/// The address the solver frame's own calldata declares as the output recipient, dispatched on -/// the attributed solver so a lookalike blob from another router cannot masquerade as one. -pub(crate) fn output_recipient(solver: &str, input: &[u8]) -> Option
{ - let (_, knowledge) = IMPLEMENTATIONS - .iter() - .find(|(name, _)| *name == solver)?; - knowledge.output_recipient(input) -} +impl SolverDecoder for NoDecoder {} -/// The output-token fee the solver's declared fee recipients were paid, when its calldata names -/// any and they received some of the bought token. -/// -/// The calldata says who is paid; the ledger says how much they got. Taking the amount from the -/// ledger keeps this clear of each router's fee encoding — `KyberSwap` declares a bps rate, not an -/// amount — and of whether the router paid the cut in the swap token or unwrapped it first. -/// -/// A frontend's cut is not visible to the address book unless its wallet is registered, so without -/// this every such trade reports the settled output short by exactly the fee, and Fynd — re-solved -/// gross — appears to win by that much. -pub(crate) fn declared_output_fee( - solver: &str, - input: &[u8], - ledger: &TransferLedger, - token_out: Address, -) -> Option { - let (_, knowledge) = IMPLEMENTATIONS +/// Resolve a solver name to its `SolverDecoder`, once, when the address book loads. A book-only +/// solver resolves to the no-op implementation. +pub(crate) fn decoder_for(solver: &str) -> &'static dyn SolverDecoder { + IMPLEMENTATIONS .iter() - .find(|(name, _)| *name == solver)?; - let recipients: HashSet
= knowledge - .fee_recipients(input) - .into_iter() - .collect(); - ledger - .received_by(&recipients) - .get(&token_out) - .copied() - .filter(|fee| !fee.is_zero()) + .find(|(name, _)| *name == solver) + .map_or(&NoDecoder, |(_, decoder)| *decoder) } /// Whether a declared quote is in the same units as the settled output. @@ -262,16 +264,19 @@ pub(crate) fn plausible_quote(quoted_amount_out: U256, settled_amount_out: U256) #[cfg(test)] mod tests { - use alloy::{ - primitives::{Bytes, Log as PrimitiveLog}, - sol_types::SolEvent, - }; - use super::*; - use crate::decoder::{ - registry::Registry, - test_utils::{addr, make_transfer_log}, - }; + use crate::decoder::registry::Registry; + + #[test] + fn test_native_sentinel_normalizes_and_leaves_every_other_token() { + assert_eq!(normalize_native(NATIVE_TOKEN_SENTINEL), Address::ZERO); + // The sentinel is matched case-insensitively by `address!`, so the checksummed spelling + // solvers write in their own constants is the same address. + let checksummed = alloy::primitives::address!("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"); + assert_eq!(normalize_native(checksummed), Address::ZERO); + let usdc = alloy::primitives::address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + assert_eq!(normalize_native(usdc), usdc); + } #[test] fn test_implementation_names_against_the_address_book() { @@ -286,38 +291,6 @@ mod tests { } } - /// A bridge-shaped log emitted by the registered `LiFi` router. - fn bridge_log(emitter: Address) -> Log { - let primitive = PrimitiveLog::new_unchecked( - emitter, - vec![lifi::LiFiTransferStarted::SIGNATURE_HASH], - Bytes::default(), - ); - Log { inner: primitive, ..Default::default() } - } - - #[test] - fn test_solver_veto_bridge_orders() { - let registry = Registry::ethereum(); - let lifi_router: Address = "0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae" - .parse() - .unwrap(); - let bridge_logs = vec![bridge_log(lifi_router)]; - assert_eq!(solver_veto(&bridge_logs, addr(1), ®istry), Some(Veto::BridgeOrder)); - - let swap_logs = vec![make_transfer_log(addr(10), addr(1), addr(2), U256::from(1000))]; - assert_eq!(solver_veto(&swap_logs, lifi_router, ®istry), None); - } - - #[test] - fn test_solver_veto_scoped_to_the_solver_present() { - // The same bridge-shaped log from an address that is not the LiFi router: LiFi is not - // part of the transaction, so its veto is never consulted. - let registry = Registry::ethereum(); - let bridge_logs = vec![bridge_log(addr(70))]; - assert_eq!(solver_veto(&bridge_logs, addr(1), ®istry), None); - } - #[test] fn test_plausible_quote_slippage_and_unit_mismatch() { // The audited Relay+KyberSwap trade: quoted 70,400.41, settled 69,996.28 — 57bps of @@ -331,68 +304,21 @@ mod tests { } #[test] - fn test_declared_output_fee_reads_the_integrator_cut() { - // Base tx 0x78c70ca6…: KyberSwap's calldata names a frontend's wallet, which took 10% of - // the ETH the trader bought. Without backing it out the settled output is 10% short and - // Fynd, re-solved gross, wins by 1111 bps on every one of that frontend's trades. - let collector = addr(41); - let trader = addr(1); - let router = addr(50); - let token_out = Address::ZERO; - let native = [ - (router, trader, U256::from(45_157_884_343_657_075u64)), - (router, collector, U256::from(5_017_542_704_850_786u64)), - ]; - let ledger = TransferLedger::from_transaction(&[], &native); - let input = kyberswap::swap_calldata(vec![collector]); - - assert_eq!( - declared_output_fee("kyberswap", &input, &ledger, token_out), - Some(U256::from(5_017_542_704_850_786u64)) - ); - // Dispatched on the attributed solver: the same calldata under another solver's name - // declares nothing. - assert_eq!(declared_output_fee("1inch", &input, &ledger, token_out), None); - // A swap that names no fee recipient has no fee to back out. - assert_eq!( - declared_output_fee( - "kyberswap", - &kyberswap::swap_calldata(Vec::new()), - &ledger, - token_out - ), - None - ); - } - - #[test] - fn test_declared_output_fee_ignores_other_tokens() { - // The recipient was paid, but in a token the trade did not buy — that is not this swap's - // output fee. - let collector = addr(41); - let logs = vec![make_transfer_log(addr(10), addr(50), collector, U256::from(85))]; - let ledger = TransferLedger::from_transaction(&logs, &[]); - let input = kyberswap::swap_calldata(vec![collector]); - assert_eq!(declared_output_fee("kyberswap", &input, &ledger, addr(11)), None); - } - - #[test] - fn test_swap_intent_dispatch_scoped_to_the_attributed_solver() { - // A ParaSwap-shaped calldata (token pair, then the fromAmount/toAmount/quotedAmount - // triple) only parses into an intent when the attributed solver is paraswap; an unlisted - // solver never yields one from the same bytes. - let amount_in = U256::from(171_521_496u64); - let mut input = vec![0xe3u8, 0xea, 0xd5, 0x9e]; - for word in [ - U256::from(0x1111u64), // srcToken - U256::from(0x2222u64), // destToken - amount_in, - U256::from(171_430_663u64), - U256::from(171_602_266u64), - ] { - input.extend_from_slice(&word.to_be_bytes::<32>()); - } - assert!(swap_intent("paraswap", &input, Some(amount_in)).is_some()); - assert!(swap_intent("1inch", &input, Some(amount_in)).is_none()); + fn test_declared_read_scoped_to_the_settling_solver() { + // Real ParaSwap calldata parses only through ParaSwap's own decoder. Another solver's + // decoder must decline the same bytes rather than force them through its own ABI, which + // is what keeps a record's amounts and its solver label on the same solver. + let text = include_str!("fixtures/paraswap_input.txt").trim(); + let input = alloy::hex::decode(text.strip_prefix("0x").unwrap_or(text)).unwrap(); + + assert!(decoder_for("paraswap") + .declared(&input, &[]) + .is_ok_and(|declared| declared.is_some())); + assert!(decoder_for("1inch") + .declared(&input, &[]) + .is_ok_and(|declared| declared.is_none())); + assert!(decoder_for("0x") + .declared(&input, &[]) + .is_ok_and(|declared| declared.is_none())); } } diff --git a/tools/hindsight/src/decoder/solvers/okx.rs b/tools/hindsight/src/decoder/solvers/okx.rs new file mode 100644 index 000000000..709326d5c --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/okx.rs @@ -0,0 +1,181 @@ +//! OKX `DexRouter` decoding. +//! +//! OKX states each settled order in its own `OrderRecord` event: both tokens, the trader, the +//! amount that entered the swap, and the amount returned. Nothing has to be recovered from the +//! ledger, so this is a log read rather than a calldata one — the same shape as `CoW`'s `Trade` +//! event, and it survives the router's several entry functions (`smartSwapByOrderId`, +//! `uniswapV3SwapTo`, …) because none of them changes the event. +//! +//! `fromAmount` is the amount that reached the pools, after OKX's own commission — the basis a +//! re-solve needs. Verified against four live Ethereum trades (blocks 25741800-25741815): every +//! `toToken`/`returnAmount` matched the settled record exactly, while `fromAmount` sat 0 to 85 bps +//! below the trader's gross spend, the commission OKX records in a separate event. + +use alloy::{rpc::types::Log, sol, sol_types::SolEvent}; + +use crate::decoder::{ + solvers::{normalize_native, DeclaredSwap, SolverDecoder}, + transfer_ledger::to_primitive_log, + veto::Veto, +}; + +sol! { + /// `DexRouter`'s per-order record. Every field is unindexed, so the whole trade sits in the + /// log's data. + event OrderRecord( + address fromToken, + address toToken, + address sender, + uint256 fromAmount, + uint256 returnAmount + ); +} + +/// The OKX solver. +pub(crate) struct Okx; + +impl SolverDecoder for Okx { + /// The settled trade, read from `OrderRecord`. Declines a transaction carrying more than one + /// record: that is several orders in one transaction, and one record is not the trade. + fn declared(&self, _input: &[u8], logs: &[Log]) -> Result, Veto> { + let mut records = logs + .iter() + .filter(|log| log.topics().first() == Some(&OrderRecord::SIGNATURE_HASH)); + let Some(first) = records.next() else { return Ok(None) }; + if records.next().is_some() { + return Ok(None); + } + let Ok(record) = OrderRecord::decode_log(&to_primitive_log(first)) else { + return Ok(None); + }; + if record.fromAmount.is_zero() || record.returnAmount.is_zero() { + return Ok(None); + } + Ok(Some(DeclaredSwap::from_event( + record.sender, + normalize_native(record.fromToken), + record.fromAmount, + normalize_native(record.toToken), + record.returnAmount, + ))) + } +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{address, b256, Address, Log as PrimitiveLog, U256}; + + use super::*; + use crate::decoder::{ + solvers::NATIVE_TOKEN_SENTINEL, + test_utils::{addr, make_transfer_log}, + }; + + /// The `DexRouter` address every sampled trade entered through. + const ROUTER: Address = address!("0x28b1dc1a5e3699a428bc51d234dfab7c9cb2a183"); + + fn order_record( + from_token: Address, + to_token: Address, + sender: Address, + from_amount: u128, + return_amount: u128, + ) -> Log { + let event = OrderRecord { + fromToken: from_token, + toToken: to_token, + sender, + fromAmount: U256::from(from_amount), + returnAmount: U256::from(return_amount), + }; + let data = event.encode_log_data(); + let primitive = + PrimitiveLog::new_unchecked(ROUTER, data.topics().to_vec(), data.data.clone()); + Log { inner: primitive, ..Default::default() } + } + + fn settled(logs: &[Log]) -> Option { + Okx.declared(&[], logs).ok().flatten() + } + + #[test] + fn test_event_signature_against_the_deployed_router() { + // The topic0 observed on every sampled trade. A wrong `sol!` declaration would compile and + // silently never match, so it is pinned here. + assert_eq!( + OrderRecord::SIGNATURE_HASH, + b256!("0x1bb43f2da90e35f7b0cf38521ca95a49e68eb42fac49924930a5bd73cdf7576c") + ); + } + + #[test] + fn test_real_trade_amounts() { + // Live tx 0x00532cf9…: USDT in, 0x423f4e61… out. `fromAmount` is 178,650 below the + // trader's gross spend of 35,730,088 — OKX's commission, which never entered the swap. + let usdt = address!("0xdac17f958d2ee523a2206206994597c13d831ec7"); + let token_out = address!("0x423f4e6138e475d85cf7ea071ac92097ed631eea"); + let trader = address!("0xe127a59e0290d038cf1b2a767f8d422451d95980"); + let flow = settled(&[order_record( + usdt, + token_out, + trader, + 35_551_438, + 699_080_168_573_611_654_796_604_356, + )]) + .unwrap(); + assert_eq!(flow.tracked, Some(trader)); + assert_eq!(flow.token_in, usdt); + assert_eq!(flow.amount_in, Some(U256::from(35_551_438u64))); + assert_eq!(flow.token_out, token_out); + assert_eq!(flow.amount_out, Some(U256::from(699_080_168_573_611_654_796_604_356u128))); + } + + #[test] + fn test_native_sentinel_normalized() { + // Live tx 0xceabae7f…: native ETH in, USDC out. OKX writes native as 0xeeee…ee. + let usdc = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + let flow = settled(&[order_record( + NATIVE_TOKEN_SENTINEL, + usdc, + addr(1), + 30_000_000_000_000_000, + 56_277_456, + )]) + .unwrap(); + assert_eq!( + flow, + DeclaredSwap::from_event( + addr(1), + Address::ZERO, + U256::from(30_000_000_000_000_000u64), + usdc, + U256::from(56_277_456u64), + ) + ); + } + + #[test] + fn test_several_orders_declined() { + // Two records in one transaction: several orders, so no single one is the trade. Left to + // the netting fallback. + let logs = vec![ + order_record(addr(10), addr(11), addr(1), 1_000, 2_000), + order_record(addr(11), addr(10), addr(2), 2_000, 1_000), + ]; + assert!(settled(&logs).is_none()); + } + + #[test] + fn test_no_record_declined() { + assert!( + settled(&[make_transfer_log(addr(10), addr(1), addr(2), U256::from(1_000))]).is_none() + ); + assert!(settled(&[]).is_none()); + } + + #[test] + fn test_zero_amounts_declined() { + assert!(settled(&[order_record(addr(10), addr(11), addr(1), 0, 2_000)]).is_none()); + assert!(settled(&[order_record(addr(10), addr(11), addr(1), 1_000, 0)]).is_none()); + } +} diff --git a/tools/hindsight/src/decoder/solvers/oneinch.rs b/tools/hindsight/src/decoder/solvers/oneinch.rs new file mode 100644 index 000000000..0373605e3 --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/oneinch.rs @@ -0,0 +1,159 @@ +//! 1inch calldata extraction. +//! +//! The v6 Aggregation Router's `swap` entry carries a `SwapDescription` struct with everything a +//! declared read needs: both tokens, the input amount, the on-chain floor, and the recipient the +//! output is paid to. Verified against a live Ethereum trade (see the fixture test): the decoded +//! `srcToken`/`amount` matched the settled record exactly, `dstReceiver` was the trader, and the +//! settled output sat 101 bps above `minReturnAmount`. +//! +//! Two other entries appear in live traffic and are deliberately declined, because neither +//! carries the trade in a form this read can recover: +//! +//! - `unoswap` and its variants pack the pools into bitmasked descriptors, with no token pair in +//! the calldata at all. +//! - `fillOrderArgs` is a limit-order fill, not a router swap. The order's `makingAmount` and +//! `takingAmount` are a price the maker signed off-chain, and a partial fill settles only part of +//! it, so reading it as a market swap would compare a signed limit price against a spot quote. +//! Those trades stay on the netting fallback until we decide what a limit order should be +//! compared against. + +use alloy::{rpc::types::Log, sol, sol_types::SolCall}; + +use crate::decoder::{ + solvers::{normalize_native, DeclaredSwap, SolverDecoder}, + veto::Veto, +}; + +sol! { + /// The v6 router's swap terms. `srcReceiver` is the executor the input is routed to and + /// `flags` is a bitfield; neither is read here. + struct SwapDescription { + address srcToken; + address dstToken; + address srcReceiver; + address dstReceiver; + uint256 amount; + uint256 minReturnAmount; + uint256 flags; + } + + /// The v6 Aggregation Router's market-swap entry (selector `0x07ed2379`). + function swap(address executor, SwapDescription desc, bytes data) + external + payable + returns (uint256 returnAmount, uint256 spentAmount); +} + +/// The 1inch solver. +pub(crate) struct OneInch; + +impl SolverDecoder for OneInch { + /// The trader's swap terms from a `swap` call's `SwapDescription`. `minReturnAmount` is passed + /// through as declared, including a zero — the router's per-hop checks can leave the top-level + /// floor at zero, and the terms are still worth recording. The hint is unused: every field is + /// read by ABI position. + fn declared(&self, input: &[u8], _logs: &[Log]) -> Result, Veto> { + let Ok(call) = swapCall::abi_decode(input) else { return Ok(None) }; + if call.desc.amount.is_zero() { + return Ok(None); + } + let intent = DeclaredSwap::from_calldata( + normalize_native(call.desc.srcToken), + normalize_native(call.desc.dstToken), + call.desc.amount, + call.desc.minReturnAmount, + ) + .with_recipient(call.desc.dstReceiver); + Ok(Some(intent)) + } +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{address, Address, Bytes, U256}; + + use super::*; + + /// The `swap` calldata of a real settled trade (tx + /// `0x8cbd0e1568faa5084dd02e83b4bc5e98d9b7b685de7f56f4fae0069698a8f1e0`): 51,014.9961 of + /// `0x73d7c860…` in, native ETH out. The settled record netted 1,541,583,057,157,647,921 wei + /// out, 101 bps above the floor below. + fn real_input() -> Vec { + let text = include_str!("fixtures/oneinch_input.txt").trim(); + alloy::hex::decode(text.strip_prefix("0x").unwrap_or(text)).unwrap() + } + + const TOKEN_IN: Address = address!("0x73d7c860998ca3c01ce8c808f5577d94d545d1b4"); + const TRADER: Address = address!("0x59e4d2324bf6bfc8f568125b8a03266c7d4a4726"); + const AMOUNT_IN: u128 = 51_014_996_100_000_000_000_000; + const MIN_AMOUNT_OUT: u128 = 1_526_167_226_586_071_441; + + fn terms(input: &[u8]) -> Option { + OneInch + .declared(input, &[]) + .ok() + .flatten() + } + + #[test] + fn test_selector_against_the_deployed_router() { + // The selector observed on live v6 swaps. A wrong `sol!` declaration would compile and + // silently never match, so it is pinned here. + assert_eq!(swapCall::SELECTOR, [0x07, 0xed, 0x23, 0x79]); + } + + #[test] + fn test_real_fixture_declared_swap() { + let intent = terms(&real_input()).unwrap(); + assert_eq!(intent.token_in, TOKEN_IN); + // The calldata names 1inch's native sentinel; the record's token_out is the zero address. + assert_eq!(intent.token_out, Address::ZERO); + assert_eq!(intent.amount_in, Some(U256::from(AMOUNT_IN))); + assert_eq!(intent.min_amount_out, Some(U256::from(MIN_AMOUNT_OUT))); + // `swap` carries no off-chain quote, so the floor is the best available promise. + assert_eq!(intent.declared_quote, None); + } + + #[test] + fn test_real_fixture_output_recipient() { + // The trader themselves, unlike Fly's calldata, which names the venue's router. + let intent = terms(&real_input()).unwrap(); + assert_eq!(intent.output_recipient, Some(TRADER)); + } + + #[test] + fn test_limit_order_and_compact_entries_declined() { + // `fillOrderArgs` (0xf497df75) is a limit-order fill and `unoswap` (0x83800a8e) packs its + // pools into bitmasks; neither is a `swap` call, so both decline rather than guess. + for selector in [[0xf4, 0x97, 0xdf, 0x75], [0x83, 0x80, 0x0a, 0x8e]] { + let mut input = selector.to_vec(); + input.extend_from_slice(&real_input()[4..]); + assert!(terms(&input).is_none()); + } + } + + #[test] + fn test_garbage_and_truncated_input_declined() { + assert!(terms(&[]).is_none()); + assert!(terms(&[0xde, 0xad, 0xbe, 0xef]).is_none()); + assert!(terms(&real_input()[..100]).is_none()); + } + + #[test] + fn test_zero_amount_declined() { + let call = swapCall { + executor: Address::ZERO, + desc: SwapDescription { + srcToken: TOKEN_IN, + dstToken: Address::ZERO, + srcReceiver: Address::ZERO, + dstReceiver: TRADER, + amount: U256::ZERO, + minReturnAmount: U256::from(1_000), + flags: U256::ZERO, + }, + data: Bytes::default(), + }; + assert!(terms(&call.abi_encode()).is_none()); + } +} diff --git a/tools/hindsight/src/decoder/solvers/paraswap.rs b/tools/hindsight/src/decoder/solvers/paraswap.rs index 48dff8cbc..1c0d6f609 100644 --- a/tools/hindsight/src/decoder/solvers/paraswap.rs +++ b/tools/hindsight/src/decoder/solvers/paraswap.rs @@ -1,223 +1,198 @@ -//! ParaSwap-specific calldata extraction. +//! `ParaSwap` (Velora) Augustus v6 calldata extraction. //! -//! Augustus v6 swap methods carry the trade parameters as consecutive 32-byte words: -//! `…, srcToken, destToken, …, fromAmount, toAmount, quotedAmount, …`. `toAmount` is the -//! slippage floor and `quotedAmount` the off-chain quoted output, kept on-chain for `ParaSwap`'s -//! surplus accounting (the user is capped at the quote, so settled often equals it exactly). The -//! triple sits at a different offset per method selector, so instead of per-selector ABI decoding -//! it is located by value: find the word equal to the trade's known input amount — the caller's -//! hint, there is no other way to find it — then read the floor-and-quote pair that follows it and -//! the token pair that precedes it. - -use alloy::primitives::{Address, U256}; - -use crate::decoder::solvers::{SolverKnowledge, SwapIntent}; - -/// Byte length of an ABI-encoded word. -const WORD_LEN: usize = 32; -/// A `bytes32` word encoding an `address` has 12 zero prefix bytes, then the 20-byte address. -const ADDRESS_PREFIX_LEN: usize = 12; - -/// Whether a word is address-shaped: 12 zero prefix bytes and a non-zero address. Rejects a -/// coincidental match against unrelated calldata bytes. -fn is_address_word(word: U256) -> bool { - let bytes = word.to_be_bytes::(); - bytes[..ADDRESS_PREFIX_LEN] - .iter() - .all(|&byte| byte == 0) && - bytes[ADDRESS_PREFIX_LEN..] - .iter() - .any(|&byte| byte != 0) -} +//! `swapExactAmountIn` carries every term in one static struct: both tokens, the input amount, the +//! floor the trade reverts below, `ParaSwap`'s own quote, and the address the output is paid to. +//! The struct holds only fixed-size fields, so it is ABI-encoded in place rather than behind an +//! offset — a plain decode reaches all of it. +//! +//! `quotedAmount` is worth more here than for most solvers: `ParaSwap` caps the user at its quote +//! and keeps the surplus, so a settled amount usually sits between the floor and the quote. +//! +//! Verified against two live Ethereum trades (blocks 25741801 and 25741809): both decoded +//! `fromAmount` matched the settled record exactly, and both settled outputs cleared the decoded +//! floor and stayed under the decoded quote. One paid an ERC-20 input, the other native ETH. + +use alloy::{rpc::types::Log, sol, sol_types::SolCall}; + +use crate::decoder::{ + solvers::{normalize_native, DeclaredSwap, SolverDecoder}, + veto::Veto, +}; -/// The address encoded in a word already known to be address-shaped. -fn address_from_word(word: U256) -> Address { - Address::from_slice(&word.to_be_bytes::()[ADDRESS_PREFIX_LEN..]) +sol! { + /// Augustus v6's generic swap terms. Every field is fixed-size, so the struct is encoded + /// inline. `metadata` is `ParaSwap`'s own bookkeeping and is not read. + struct SwapExactAmountInParams { + address srcToken; + address destToken; + uint256 fromAmount; + uint256 toAmount; + uint256 quotedAmount; + bytes32 metadata; + address beneficiary; + } + + /// Augustus v6's generic entry (selector `0xe3ead59e`). + function swapExactAmountIn( + address executor, + SwapExactAmountInParams params, + uint256 partnerAndFee, + bytes permit, + bytes executorData + ) external payable returns (uint256 receivedAmount, uint256 paraswapShare, uint256 partnerShare); } /// The `ParaSwap` solver. pub(crate) struct Paraswap; -impl SolverKnowledge for Paraswap { - /// Extract the trader's swap terms from Augustus calldata: the enforced floor and declared - /// quote by scanning for the word equal to `amount_in_hint`, the tokens from the two words - /// immediately preceding it. +impl SolverDecoder for Paraswap { + /// The trader's swap terms from a `swapExactAmountIn` call's params. /// - /// A false positive would need a word that equals the exact input amount *and* is followed - /// by a plausible floor/quote pair (`0 < toAmount <= quotedAmount <= 2 * toAmount`) *and* - /// preceded by two address-shaped words — and the caller's settled-amount plausibility check - /// still applies to the quote afterward. Returns `None` when no such shape exists (e.g. a - /// partner fee made the decoded input differ from `fromAmount`, or the words before it are - /// not a token pair) — the intent is lost along with the quote, since there is nothing left - /// to recover the floor from. A reverted trade has no netted flow to draw a hint from, so - /// `amount_in_hint: None` always yields `None`. - fn swap_intent(&self, input: &[u8], amount_in_hint: Option) -> Option { - let amount_in = amount_in_hint.filter(|hint| !hint.is_zero())?; - if input.len() < 4 { - return None; + /// `beneficiary` is the declared output recipient, except when it is the zero address — + /// Augustus reads that as "pay the caller", so it is left unset and the caller anchors on the + /// transaction sender. A floor above the quote is inconsistent (the quote is what `ParaSwap` + /// promised, the floor what it would accept) and is declined rather than recorded. + fn declared(&self, input: &[u8], _logs: &[Log]) -> Result, Veto> { + let Ok(call) = swapExactAmountInCall::abi_decode(input) else { return Ok(None) }; + let params = call.params; + if params.fromAmount.is_zero() || params.toAmount.is_zero() { + return Ok(None); } - let words: Vec = input[4..] - .as_chunks::() - .0 - .iter() - .map(|word| U256::from_be_slice(word)) - .collect(); - for (index, window) in words.windows(3).enumerate() { - let (from_amount, to_amount, quoted) = (window[0], window[1], window[2]); - if from_amount != amount_in || to_amount.is_zero() { - continue; - } - if quoted < to_amount || quoted > to_amount.saturating_mul(U256::from(2)) { - continue; - } - if index < 2 { - continue; - } - let (src_token, dst_token) = (words[index - 2], words[index - 1]); - if !is_address_word(src_token) || !is_address_word(dst_token) { - continue; - } - let intent = SwapIntent::new( - address_from_word(src_token), - address_from_word(dst_token), - amount_in, - to_amount, - ); - return Some(intent.with_quote(quoted, None)); + if params.toAmount > params.quotedAmount { + return Ok(None); } - None + let declared = DeclaredSwap::from_calldata( + normalize_native(params.srcToken), + normalize_native(params.destToken), + params.fromAmount, + params.toAmount, + ) + .with_quote(params.quotedAmount, None); + Ok(Some(if params.beneficiary.is_zero() { + declared + } else { + declared.with_recipient(params.beneficiary) + })) } } #[cfg(test)] mod tests { + use alloy::primitives::{address, Address, U256}; + use super::*; + use crate::decoder::solvers::NATIVE_TOKEN_SENTINEL; + + /// The `swapExactAmountIn` calldata of a real settled trade (tx `0x6bb77fe6…`, block + /// 25741801): 5,599.115792 of `0xce6170ea…` in, WETH out. The settled record netted + /// 2,997,199,455,534,478,910 wei out — just above the floor below, under the quote. + fn real_input() -> Vec { + let text = include_str!("fixtures/paraswap_input.txt").trim(); + alloy::hex::decode(text.strip_prefix("0x").unwrap_or(text)).unwrap() + } - /// Word-aligned Augustus-style calldata: selector, then 32-byte words. - fn calldata(words: &[U256]) -> Vec { - let mut input = vec![0xe3u8, 0xea, 0xd5, 0x9e]; - for word in words { - input.extend_from_slice(&word.to_be_bytes::<32>()); + const TOKEN_IN: Address = address!("0xce6170ea245dc8d1f275a710a062b70f125f0110"); + const WETH: Address = address!("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"); + const BENEFICIARY: Address = address!("0xfdff0b569f14af593d446e51b3e42f502124ac85"); + const AMOUNT_IN: u64 = 5_599_115_792; + const FLOOR: u128 = 2_997_199_455_316_012_230; + const QUOTE: u128 = 2_997_499_205_236_535_884; + + fn terms(input: &[u8]) -> Option { + Paraswap + .declared(input, &[]) + .ok() + .flatten() + } + + /// A `swapExactAmountIn` call encoded through the `sol!` types, mirroring a real trade. + fn call_with( + src: Address, + dst: Address, + from_amount: u64, + to_amount: u64, + quoted: u64, + beneficiary: Address, + ) -> Vec { + swapExactAmountInCall { + executor: Address::ZERO, + params: SwapExactAmountInParams { + srcToken: src, + destToken: dst, + fromAmount: U256::from(from_amount), + toAmount: U256::from(to_amount), + quotedAmount: U256::from(quoted), + metadata: alloy::primitives::B256::ZERO, + beneficiary, + }, + partnerAndFee: U256::ZERO, + permit: alloy::primitives::Bytes::default(), + executorData: alloy::primitives::Bytes::default(), } - input + .abi_encode() + } + + #[test] + fn test_selector_against_the_deployed_router() { + // The selector observed on both sampled trades. A wrong `sol!` declaration would compile + // and silently never match, so it is pinned here. + assert_eq!(swapExactAmountInCall::SELECTOR, [0xe3, 0xea, 0xd5, 0x9e]); + } + + #[test] + fn test_real_fixture_declared_swap() { + let declared = terms(&real_input()).unwrap(); + assert_eq!(declared.token_in, TOKEN_IN); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_in, Some(U256::from(AMOUNT_IN))); + assert_eq!(declared.min_amount_out, Some(U256::from(FLOOR))); + assert_eq!(declared.declared_quote, Some(U256::from(QUOTE))); + // Calldata states no settled output, so the caller recovers it. + assert_eq!(declared.amount_out, None); + assert_eq!(declared.tracked, None); } #[test] - fn test_real_swap_exact_amount_in_layout() { - // Live tx 0x1192b394… (block range of run5): srcToken, destToken, fromAmount, toAmount - // (floor, -10bps), quotedAmount — the settled amount was 171,602,265, one unit under the - // quote (ParaSwap caps the user at the quote and keeps the surplus). - let src_token = U256::from(0x1111u64); - let dst_token = U256::from(0x2222u64); - let amount_in = U256::from(171_521_496u64); - let words = [ - src_token, - dst_token, - amount_in, - U256::from(171_430_663u64), - U256::from(171_602_266u64), - U256::ZERO, // metadata - ]; - let intent = Paraswap - .swap_intent(&calldata(&words), Some(amount_in)) - .unwrap(); - assert_eq!(intent.token_in, address_from_word(src_token)); - assert_eq!(intent.token_out, address_from_word(dst_token)); - assert_eq!(intent.amount_in, amount_in); - assert_eq!(intent.min_amount_out, U256::from(171_430_663u64)); - assert_eq!(intent.quoted_amount_out(), U256::from(171_602_266u64)); + fn test_real_fixture_beneficiary_is_the_output_recipient() { + let declared = terms(&real_input()).unwrap(); + assert_eq!(declared.output_recipient, Some(BENEFICIARY)); } #[test] - fn test_missing_input_amount() { - // A partner fee (or any decode difference) means no word equals the decoded input. - let words = [ - U256::from(0x1111u64), - U256::from(0x2222u64), - U256::from(171_521_496u64), - U256::from(171_430_663u64), - U256::from(171_602_266u64), - ]; - assert!(Paraswap - .swap_intent(&calldata(&words), Some(U256::from(999u64))) - .is_none()); - assert!(Paraswap - .swap_intent(&[], Some(U256::from(1u64))) - .is_none()); - assert!(Paraswap - .swap_intent(&calldata(&words), Some(U256::ZERO)) - .is_none()); + fn test_native_sentinel_normalized() { + // Live tx 0xd427bdec… paid native ETH in, which Augustus writes as 0xeeee…ee. + let call = call_with(NATIVE_TOKEN_SENTINEL, WETH, 1_000, 900, 950, Address::ZERO); + assert_eq!(terms(&call).unwrap().token_in, Address::ZERO); } #[test] - fn test_no_hint_never_yields_an_intent() { - // The revert path: no netted flow means no hint, so there is no way to locate the - // triple even when the calldata is otherwise well-formed. - let words = [ - U256::from(0x1111u64), - U256::from(0x2222u64), - U256::from(171_521_496u64), - U256::from(171_430_663u64), - U256::from(171_602_266u64), - ]; - assert!(Paraswap - .swap_intent(&calldata(&words), None) - .is_none()); + fn test_zero_beneficiary_leaves_the_recipient_unset() { + // Augustus reads a zero beneficiary as "pay the caller", so there is no declared + // recipient and the caller anchors on the transaction sender instead. + let call = call_with(TOKEN_IN, WETH, 1_000, 900, 950, Address::ZERO); + assert_eq!(terms(&call).unwrap().output_recipient, None); } #[test] - fn test_implausible_floor_quote_pair() { - // The words after the input match are not a floor/quote pair: quote below the floor, or - // wildly above it (different units). - let amount_in = U256::from(1_000_000u64); - let below = [ - U256::from(0x1111u64), - U256::from(0x2222u64), - amount_in, - U256::from(990_000u64), - U256::from(400_000u64), - ]; - assert!(Paraswap - .swap_intent(&calldata(&below), Some(amount_in)) - .is_none()); - let far_above = [ - U256::from(0x1111u64), - U256::from(0x2222u64), - amount_in, - U256::from(990_000u64), - U256::from(10_000_000u64), - ]; - assert!(Paraswap - .swap_intent(&calldata(&far_above), Some(amount_in)) - .is_none()); + fn test_zero_amounts_declined() { + assert!(terms(&call_with(TOKEN_IN, WETH, 0, 900, 950, Address::ZERO)).is_none()); + assert!(terms(&call_with(TOKEN_IN, WETH, 1_000, 0, 950, Address::ZERO)).is_none()); } #[test] - fn test_non_address_shaped_tokens_rejected() { - // The words before fromAmount are not address-shaped (top bytes set): the intent is - // dropped even though the floor/quote pair itself is plausible — there is nothing to - // build a trustworthy token pair from. - let amount_in = U256::from(1_000_000u64); - let words = [ - U256::MAX, // not address-shaped: every byte set - U256::from(0x2222u64), - amount_in, - U256::from(990_000u64), - U256::from(995_000u64), - ]; - assert!(Paraswap - .swap_intent(&calldata(&words), Some(amount_in)) - .is_none()); + fn test_floor_above_quote_declined() { + // The floor cannot exceed the amount ParaSwap quoted; such calldata is inconsistent. + assert!(terms(&call_with(TOKEN_IN, WETH, 1_000, 960, 950, Address::ZERO)).is_none()); } #[test] - fn test_fromamount_too_early_for_token_words() { - // fromAmount at the very start of the calldata: no room for the two preceding token - // words, even though the hint matches and the floor/quote pair is plausible. - let amount_in = U256::from(1_000_000u64); - let words = [amount_in, U256::from(990_000u64), U256::from(995_000u64)]; - assert!(Paraswap - .swap_intent(&calldata(&words), Some(amount_in)) - .is_none()); + fn test_garbage_and_truncated_input_declined() { + assert!(terms(&[]).is_none()); + assert!(terms(&[0xde, 0xad, 0xbe, 0xef]).is_none()); + assert!(terms(&real_input()[..100]).is_none()); + // Another Augustus entry never decodes as this one. + let mut wrong = real_input(); + wrong[0] = 0xff; + assert!(terms(&wrong).is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/uniswap.rs b/tools/hindsight/src/decoder/solvers/uniswap.rs new file mode 100644 index 000000000..915dd52c0 --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/uniswap.rs @@ -0,0 +1,1387 @@ +//! Uniswap Universal Router calldata extraction. +//! +//! `execute(bytes commands, bytes[] inputs, uint256 deadline)` is a command stream: each byte of +//! `commands` names an operation and reads its parameters from the matching element of `inputs` +//! (docs: developers.uniswap.org/docs/protocols/universal-router/concepts/commands). Four commands +//! carry the trader's terms: +//! +//! - `V3_SWAP_EXACT_IN` (`0x00`) — recipient, amount in, floor, then a packed v3 path (20-byte +//! token, 3-byte fee, repeating). +//! - `V3_SWAP_EXACT_OUT` (`0x01`) — recipient, amount out, ceiling, then the same path encoded +//! output-first. +//! - `V2_SWAP_EXACT_IN` (`0x08`) and `V2_SWAP_EXACT_OUT` (`0x09`) — the same three, then an +//! `address[]` path, which always runs input to output. +//! +//! The parameters are read by word position rather than as a fixed tuple: Universal Router 2.1.1 +//! appends a `minHopPriceX36` array that a strict decode would reject, and the leading words have +//! not moved. +//! +//! An exact-output command states the settled output outright and only bounds the input, so the +//! caller recovers what was spent from the payer's net payment. That is the mirror of the +//! exact-input case, where the input is stated and the output is recovered from the recipient's +//! receipt. +//! +//! `WRAP_ETH` and `UNWRAP_WETH` bracket a swap whose path names WETH but whose trader side is +//! native ETH, so they rewrite the corresponding token to `Address::ZERO`. See `trader_tokens`. +//! +//! `V4_SWAP` (`0x10`) carries a nested stream of its own: `abi.encode(bytes actions, bytes[] +//! params)`, with four swap actions. `SWAP_EXACT_IN_SINGLE` (`0x06`) and `SWAP_EXACT_OUT_SINGLE` +//! (`0x08`) hold a `PoolKey`, the swap direction, and the two amounts; `SWAP_EXACT_IN` (`0x07`) +//! and `SWAP_EXACT_OUT` (`0x09`) hold one named currency, a `PathKey[]` of hops, then the same +//! two amounts. v4 names native ETH as the zero address directly, with no wrapping, so its +//! currencies need no translation. A single pool's two currencies are sorted, so `zeroForOne` says +//! which is being sold. +//! +//! Universal Router 2.1.1 inserts a `minHopPriceX36` field into all four structs. In the +//! single-pool pair it lands after every field read here. In the multi-hop pair it lands as an +//! array *before* the amounts, moving both one word later — so the amounts are found by locating +//! the end of the struct's head rather than at fixed positions. See `read_v4_multi_hop`. +//! +//! What is declined, measured over live traffic (40 Universal Router trades on Ethereum blocks +//! 25741800-25741815, carrying 28 v4 swaps between them): +//! +//! - **More than one swap in the stream**, counting v2, v3 and v4 together. A split route has no +//! single command that is the trade, and a route can begin in v3 and finish in v4 — one sampled +//! trade did, where reading only its v3 leg reported the wrong `token_out`. +//! - **A route that ends in the token it started from.** Two of the four sampled multi-hop v4 swaps +//! were USDC to USDC. That is a bot cycling a pool, not a trade a re-solve can price. +//! - **A swap whose input the calldata cannot name**: an `UNWRAP_WETH` before the swap means the +//! trader paid wrapped native and the pool wants native, so the params name `Address::ZERO` where +//! the trader's own token was the wrapped one. Every exact-output v4 swap in the sample was this +//! shape, as `PERMIT2_TRANSFER_FROM UNWRAP_WETH V4_SWAP WRAP_ETH`. +//! +//! Verified on the sample: 10 v3/v2 trades, 16 v4 exact-in-single swaps, and the 4 multi-hop v4 +//! swaps (two Universal Router v2, one 2.1.1, one carrying 97 bytes of hook data per hop). Every +//! decoded token pair matched the settled record, and every `amount_in` matched exactly except one +//! v3 trade reading 0.87% lower — a fee taken before the swap, so the calldata figure is the +//! amount that reached the pools, which is the basis a re-solve needs. +//! +//! Exact output was measured over a second range, Ethereum blocks 25826759-25826908: 26 records +//! moved from netted to declared, 23 of them on amounts identical to netting's, and none was lost. + +use alloy::{ + primitives::{Address, U256}, + rpc::types::Log, + sol, + sol_types::SolCall, +}; + +use crate::decoder::{ + solvers::{DeclaredSwap, SolverDecoder}, + veto::Veto, +}; + +sol! { + /// The Universal Router's entry point (selector `0x3593564c`). `commands` and `inputs` are + /// read positionally; `deadline` is not. + function execute(bytes commands, bytes[] inputs, uint256 deadline) external payable; + + /// The same entry without a deadline (selector `0x24856bc3`), which integrators also call. + /// The command stream is identical, so both decode through one path. + function execute(bytes commands, bytes[] inputs) external payable; +} + +/// The command byte's low bits name the operation; the high bit is an allow-revert flag +/// (`Commands.COMMAND_TYPE_MASK`). +const COMMAND_TYPE_MASK: u8 = 0x7f; + +const V3_SWAP_EXACT_IN: u8 = 0x00; +const V3_SWAP_EXACT_OUT: u8 = 0x01; +const V2_SWAP_EXACT_IN: u8 = 0x08; +const V2_SWAP_EXACT_OUT: u8 = 0x09; +const SWEEP: u8 = 0x04; +const WRAP_ETH: u8 = 0x0b; +const UNWRAP_WETH: u8 = 0x0c; +const V4_SWAP: u8 = 0x10; + +/// v4's own action bytes, inside a `V4_SWAP` command (`v4-periphery`'s `Actions` library). All +/// four swap actions are read; the rest move value without stating terms. +const V4_SWAP_EXACT_IN_SINGLE: u8 = 0x06; +const V4_SWAP_EXACT_IN: u8 = 0x07; +const V4_SWAP_EXACT_OUT_SINGLE: u8 = 0x08; +const V4_SWAP_EXACT_OUT: u8 = 0x09; + +const WORD: usize = 32; +const ADDRESS_LEN: usize = 20; +/// A v3 path element: a 20-byte token then a 3-byte fee tier. +const V3_HOP: usize = ADDRESS_LEN + 3; + +/// `Constants.MSG_SENDER` and `Constants.ADDRESS_THIS`: recipient sentinels the router resolves +/// at run time to the caller and to itself. Neither is an address whose receipt can be read. +const MSG_SENDER: u64 = 1; +const ADDRESS_THIS: u64 = 2; + +/// Which side of the trade a swap command's calldata fixes. The other side is only bounded, and +/// the two swap the meaning of a params struct's named currency and of its two amounts. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Side { + ExactIn, + ExactOut, +} + +/// The two amounts a swap command states: the side it fixes, and the bound it enforces on the +/// other side. +enum Amounts { + ExactIn { amount_in: U256, min_amount_out: U256 }, + ExactOut { amount_out: U256, max_amount_in: U256 }, +} + +impl Amounts { + /// Whether the fixed side is zero, which no real trade states. + fn is_zero(&self) -> bool { + match self { + Self::ExactIn { amount_in, .. } => amount_in.is_zero(), + Self::ExactOut { amount_out, .. } => amount_out.is_zero(), + } + } +} + +/// One swap command's terms. +struct Swap { + token_in: Address, + token_out: Address, + amounts: Amounts, + /// The command's declared recipient, unless it is a sentinel. + recipient: Option
, +} + +/// A reader of one v4 swap action's params blob: single-pool or multi-hop, either side fixed. +type ReadV4Params = fn(&[u8], Side) -> Option; + +/// The command that pays the trade's output out to the trader, when the swap sent it to the router +/// instead of naming an address. +/// +/// A swap that pays the router names `ADDRESS_THIS` and enforces **no floor at all** — the floor +/// moves to this command, along with the trader's address. Reading it matters twice over: without +/// the address, `recover_output` falls back to the transaction sender, which for an ERC-4337 +/// bundle is the bundler; and without the floor there is nothing to reject the bundler's gas +/// refund with. +#[derive(Clone, Copy)] +struct Payout { + /// The token paid out. `Address::ZERO` for `UNWRAP_WETH`, which always pays native ETH. + token: Address, + recipient: Address, + min_amount_out: U256, +} + +/// `UNWRAP_WETH(address recipient, uint256 amountMinimum)`. +fn read_unwrap(input: &[u8]) -> Option { + Some(Payout { + token: Address::ZERO, + recipient: readable_recipient(address_at(input, 0)?)?, + min_amount_out: word(input, 1)?, + }) +} + +/// `SWEEP(address token, address recipient, uint256 amountMinimum)`. +fn read_sweep(input: &[u8]) -> Option { + Some(Payout { + token: address_at(input, 0)?, + recipient: readable_recipient(address_at(input, 1)?)?, + min_amount_out: word(input, 2)?, + }) +} + +/// Read the 32-byte word at `index`, or `None` when the input is shorter. +fn word(input: &[u8], index: usize) -> Option { + input + .get(index * WORD..(index + 1) * WORD) + .map(U256::from_be_slice) +} + +/// Read the address in the low 20 bytes of the word at `index`. +fn address_at(input: &[u8], index: usize) -> Option
{ + let bytes = input.get(index * WORD + WORD - ADDRESS_LEN..(index + 1) * WORD)?; + Some(Address::from_slice(bytes)) +} + +/// The bytes of element `index` of a `bytes[]` whose length word sits at `array_offset`. +/// +/// An array element's offset is measured from the start of the array's data, after the length +/// word — not from the start of the enclosing blob, which is what `dynamic_at` assumes. +fn array_element(input: &[u8], array_offset: usize, index: usize) -> Option<&[u8]> { + let data = array_offset + WORD; + let relative = usize::try_from(word(input, data / WORD + index)?).ok()?; + let start = data + relative; + let length = usize::try_from(word(input, start / WORD)?).ok()?; + input.get(start + WORD..start + WORD + length) +} + +/// The blob position of element `index` of an array of dynamic structs, whose length word sits at +/// `array_offset`. +/// +/// A struct element carries no length word — unlike a `bytes[]` element, which `array_element` +/// reads — so the offset lands on the struct's own first head word. +fn struct_element(input: &[u8], array_offset: usize, index: usize) -> Option { + let data = array_offset + WORD; + let relative = usize::try_from(word(input, data / WORD + index)?).ok()?; + Some(data + relative) +} + +/// The bytes of a dynamic field whose offset word sits at `index`. +fn dynamic_at(input: &[u8], index: usize) -> Option<&[u8]> { + let offset = usize::try_from(word(input, index)?).ok()?; + let length = usize::try_from(word(input, offset / WORD)?).ok()?; + input.get(offset + WORD..offset + WORD + length) +} + +/// The first and last token of a packed v3 path: `token (fee token)+`. A path shorter than one hop +/// is malformed. +fn v3_path_ends(path: &[u8]) -> Option<(Address, Address)> { + if path.len() < V3_HOP + ADDRESS_LEN || !(path.len() - ADDRESS_LEN).is_multiple_of(V3_HOP) { + return None; + } + let first = Address::from_slice(path.get(..ADDRESS_LEN)?); + let last = Address::from_slice(path.get(path.len() - ADDRESS_LEN..)?); + Some((first, last)) +} + +/// The first and last token of a v2 `address[]` path, whose offset word sits at `index`. +fn v2_path_ends(input: &[u8], index: usize) -> Option<(Address, Address)> { + let offset = usize::try_from(word(input, index)?).ok()?; + let length = usize::try_from(word(input, offset / WORD)?).ok()?; + if length < 2 { + return None; + } + let base = offset / WORD + 1; + Some((address_at(input, base)?, address_at(input, base + length - 1)?)) +} + +/// The first head word of a params struct. A dynamic struct — every one read here ends in `bytes +/// hookData` — is encoded behind its own offset word when it is `abi.encode`d alone, and without +/// one when it is already the payload of a `bytes[]` element. +fn struct_base(params: &[u8]) -> Option { + Some(usize::from(word(params, 0)? == U256::from(WORD))) +} + +/// `IV4Router.ExactInputSingleParams` and `ExactOutputSingleParams` — one pool, both amounts in +/// the same two positions: +/// +/// ```text +/// currency0 currency1 fee tickSpacing hooks zeroForOne fixed bound +/// ``` +/// +/// The pool's currencies are sorted, so `zeroForOne` says which one is being sold, whichever side +/// is fixed. Universal Router 2.1.1 appends `minHopPriceX36` after the bound, past every read +/// here. +/// +/// There is no recipient: a later `TAKE`/`TAKE_ALL` action pays the output out, so the caller +/// reads the transaction sender's receipt. +fn read_v4_single(params: &[u8], side: Side) -> Option { + let base = struct_base(params)?; + let currency0 = address_at(params, base)?; + let currency1 = address_at(params, base + 1)?; + let zero_for_one = !word(params, base + 5)?.is_zero(); + let (token_in, token_out) = + if zero_for_one { (currency0, currency1) } else { (currency1, currency0) }; + let fixed = word(params, base + 6)?; + let bound = word(params, base + 7)?; + Some(Swap { + token_in, + token_out, + amounts: match side { + Side::ExactIn => Amounts::ExactIn { amount_in: fixed, min_amount_out: bound }, + Side::ExactOut => Amounts::ExactOut { amount_out: fixed, max_amount_in: bound }, + }, + recipient: None, + }) +} + +/// The head length of `ExactInputParams` and `ExactOutputParams` in the two deployed layouts: four +/// words on Universal Router v2, five on 2.1.1, which inserts a `minHopPricesX36` array offset. +const V4_MULTI_HOP_HEAD_WORDS: std::ops::RangeInclusive = 4..=5; + +/// `IV4Router.ExactInputParams` and `ExactOutputParams` — a path of hops: +/// +/// ```text +/// currencyIn path[] amountIn amountOutMinimum +/// currencyOut path[] amountOut amountInMaximum +/// ``` +/// +/// Universal Router 2.1.1 inserts a `minHopPricesX36` array between `path` and the amounts, so +/// both amounts sit one word later there. Rather than branch on the router address, this finds +/// where the struct's head ends: `path[]`'s offset is measured from the struct's start, so it *is* +/// the head length, and the two amounts are always the head's last two words. +/// +/// The named currency is the fixed side, and the far end of the path is the other side: the last +/// hop's `intermediateCurrency` for exact input, the first hop's for exact output, since +/// `_swapExactOutput` walks the path backwards from `currencyOut`. `intermediateCurrency` is +/// `PathKey`'s own first field in both layouts, and `PathKey` ends in `bytes hookData`, so each +/// hop sits behind its own offset and a hop carrying hook data does not move the hops after it. +fn read_v4_multi_hop(params: &[u8], side: Side) -> Option { + let base = struct_base(params)?; + let named = address_at(params, base)?; + let path_offset = usize::try_from(word(params, base + 1)?).ok()?; + let head_words = path_offset / WORD; + if !path_offset.is_multiple_of(WORD) || !V4_MULTI_HOP_HEAD_WORDS.contains(&head_words) { + return None; + } + let fixed = word(params, base + head_words - 2)?; + let bound = word(params, base + head_words - 1)?; + // Both amounts are `uint128`. A word too large to be one is an offset or an address, so the + // head does not end where `path[]`'s offset says it does. + if fixed > U256::from(u128::MAX) || bound > U256::from(u128::MAX) { + return None; + } + let path = base * WORD + path_offset; + let hops = usize::try_from(word(params, path / WORD)?).ok()?; + // A path with more hops than the blob has words is malformed, and the bound keeps the element + // index below the width `struct_element` adds it at. + if hops == 0 || hops > params.len() / WORD { + return None; + } + let far = struct_element(params, path, if side == Side::ExactIn { hops - 1 } else { 0 })?; + if !far.is_multiple_of(WORD) { + return None; + } + let far = address_at(params, far / WORD)?; + Some(match side { + Side::ExactIn => Swap { + token_in: named, + token_out: far, + amounts: Amounts::ExactIn { amount_in: fixed, min_amount_out: bound }, + recipient: None, + }, + Side::ExactOut => Swap { + token_in: far, + token_out: named, + amounts: Amounts::ExactOut { amount_out: fixed, max_amount_in: bound }, + recipient: None, + }, + }) +} + +/// The one swap in a `V4_SWAP` command's nested action stream, or `None` when it carries none, +/// several, or a shape this does not read. +/// +/// The command's input is `abi.encode(bytes actions, bytes[] params)`: action `index` reads +/// element `index` of `params`. +fn read_v4_swap(command_input: &[u8]) -> Option { + let actions = dynamic_at(command_input, 0)?; + let params_offset = usize::try_from(word(command_input, 1)?).ok()?; + let count = usize::try_from(word(command_input, params_offset / WORD)?).ok()?; + let mut found = None; + for (index, action) in actions.iter().enumerate() { + let (read, side): (ReadV4Params, Side) = match *action { + V4_SWAP_EXACT_IN_SINGLE => (read_v4_single, Side::ExactIn), + V4_SWAP_EXACT_IN => (read_v4_multi_hop, Side::ExactIn), + V4_SWAP_EXACT_OUT_SINGLE => (read_v4_single, Side::ExactOut), + V4_SWAP_EXACT_OUT => (read_v4_multi_hop, Side::ExactOut), + _ => continue, + }; + if found.is_some() || index >= count { + return None; + } + found = Some(read(array_element(command_input, params_offset, index)?, side)?); + } + found +} + +/// A recipient that names an address whose receipt can be read, or `None` for the router's +/// sentinels — the caller then anchors on the transaction sender, which is where the router's +/// `SWEEP`/`UNWRAP_WETH` sends the output. +fn readable_recipient(raw: Address) -> Option
{ + let sentinel = raw.into_word().into(); + let sentinel = U256::from_be_bytes::<32>(sentinel); + if sentinel == U256::from(MSG_SENDER) || sentinel == U256::from(ADDRESS_THIS) { + return None; + } + Some(raw) +} + +/// One v3 or v2 swap command's terms, by command type. All four share the same leading three +/// parameters — recipient, the fixed amount, the bound — and differ in how the path is encoded. +/// +/// Uniswap encodes an exact-output v3 path output-first, so its ends are read the other way round. +/// A v2 path always runs input to output. +fn read_swap(command: u8, input: &[u8]) -> Option { + let recipient = readable_recipient(address_at(input, 0)?); + let fixed = word(input, 1)?; + let bound = word(input, 2)?; + let (first, last) = match command { + V3_SWAP_EXACT_IN | V3_SWAP_EXACT_OUT => v3_path_ends(dynamic_at(input, 3)?)?, + _ => v2_path_ends(input, 3)?, + }; + Some(match command { + V3_SWAP_EXACT_OUT => Swap { + token_in: last, + token_out: first, + amounts: Amounts::ExactOut { amount_out: fixed, max_amount_in: bound }, + recipient, + }, + V2_SWAP_EXACT_OUT => Swap { + token_in: first, + token_out: last, + amounts: Amounts::ExactOut { amount_out: fixed, max_amount_in: bound }, + recipient, + }, + _ => Swap { + token_in: first, + token_out: last, + amounts: Amounts::ExactIn { amount_in: fixed, min_amount_out: bound }, + recipient, + }, + }) +} + +/// The command stream of either `execute` overload: with a deadline or without. The two carry +/// the same commands and inputs, so the rest of the read does not care which was called. +fn command_stream( + input: &[u8], +) -> Option<(alloy::primitives::Bytes, Vec)> { + if let Ok(call) = execute_0Call::abi_decode(input) { + return Some((call.commands, call.inputs)); + } + let call = execute_1Call::abi_decode(input).ok()?; + Some((call.commands, call.inputs)) +} + +/// The Uniswap Universal Router solver. +pub(crate) struct Uniswap; + +/// The trader's own two tokens, once the wrap commands bracketing the swap are accounted for: a +/// path names WETH where the trader's side is native ETH. +/// +/// Where the command sits relative to the swap is what makes it readable, because both commands +/// appear on either side of a trade: +/// +/// - A `WRAP_ETH` **before** the swap wraps what the trader sent, so the input is native ETH. One +/// after the swap re-wraps change and says nothing about the trader's tokens — seen live as +/// `PERMIT2_TRANSFER_FROM UNWRAP_WETH V4_SWAP WRAP_ETH`, where the trader pays WETH, the router +/// unwraps it for a native-ETH v4 pool, and re-wraps the remainder to return it. +/// - An `UNWRAP_WETH` **after** the swap pays a native output. One before the swap is feeding the +/// pool, as in that same stream. +/// +/// A native input wins over a later unwrap: an exact-output swap is funded up to its ceiling and +/// sweeps the remainder back, so it carries both, and that unwrap returns the input. +fn trader_tokens(swap: &Swap, wrapped_before: bool, unwrapped_after: bool) -> (Address, Address) { + if wrapped_before { + return (Address::ZERO, swap.token_out); + } + if unwrapped_after { + return (swap.token_in, Address::ZERO); + } + (swap.token_in, swap.token_out) +} + +/// Everything one `execute` command stream says about the trade. +struct Stream { + swap: Swap, + /// A `WRAP_ETH` before the swap: the trader paid native ETH. + wrapped_before: bool, + /// An `UNWRAP_WETH` before the swap: the trader paid wrapped native into a native pool. + unwrapped_before: bool, + /// An `UNWRAP_WETH` after the swap: the trader is paid native ETH. + unwrapped_after: bool, + /// The first command after the swap that pays the output out. + payout: Option, +} + +/// Walk an `execute` command stream for its one swap and the commands around it. +/// +/// `None` when the stream carries no swap, or more than one: a route split across commands has no +/// single one that is the trade, whichever pool versions they name. +fn read_stream(commands: &[u8], inputs: &[alloy::primitives::Bytes]) -> Option { + let mut swap = None; + let mut wrapped_before = false; + let mut unwrapped_before = false; + let mut unwrapped_after = false; + let mut payout = None; + for (command, command_input) in commands.iter().zip(inputs.iter()) { + let read = match command & COMMAND_TYPE_MASK { + WRAP_ETH => { + wrapped_before |= swap.is_none(); + continue; + } + UNWRAP_WETH => { + if swap.is_some() { + unwrapped_after = true; + payout = payout.or_else(|| read_unwrap(command_input)); + } else { + unwrapped_before = true; + } + continue; + } + SWEEP => { + if swap.is_some() { + payout = payout.or_else(|| read_sweep(command_input)); + } + continue; + } + V4_SWAP => read_v4_swap(command_input), + command @ (V3_SWAP_EXACT_IN | V2_SWAP_EXACT_IN | V3_SWAP_EXACT_OUT | + V2_SWAP_EXACT_OUT) => read_swap(command, command_input), + _ => continue, + }; + if swap.is_some() { + return None; + } + swap = Some(read?); + } + Some(Stream { swap: swap?, wrapped_before, unwrapped_before, unwrapped_after, payout }) +} + +impl SolverDecoder for Uniswap { + /// The trader's swap terms from the one swap in an `execute` command stream, whether it names a + /// v2, v3 or v4 pool and whether it fixes the input or the output. + /// + /// An exact-output swap states the settled output and only a ceiling on the input, so the + /// caller recovers what was spent from the payer's net payment. + /// + /// A swap that pays the router rather than the trader names neither a recipient nor a floor, so + /// both are taken from the `UNWRAP_WETH` or `SWEEP` that pays the output out. + /// + /// Declines a stream carrying more than one swap, or a path that ends in the token it started + /// from — see the module docs for why neither can be priced. + fn declared(&self, input: &[u8], _logs: &[Log]) -> Result, Veto> { + let Some((commands, inputs)) = command_stream(input) else { return Ok(None) }; + let Some(stream) = read_stream(&commands, &inputs) else { return Ok(None) }; + let swap = stream.swap; + if swap.amounts.is_zero() { + return Ok(None); + } + // An `UNWRAP_WETH` before the swap turns the trader's wrapped native into the native ETH + // the pool wants, so the swap names native where the trader paid the wrapped token. Naming + // that token needs the chain's wrapped-native address, which this reader does not have, so + // the transaction goes to netting instead of guessing. + if stream.unwrapped_before && !stream.wrapped_before { + return Ok(None); + } + let (token_in, token_out) = + trader_tokens(&swap, stream.wrapped_before, stream.unwrapped_after); + // A path that ends in the token it started from is a bot cycling pools, not a trade a + // re-solve can price. + if token_in == token_out { + return Ok(None); + } + // Only a payout of this trade's own output token says anything about it. + let payout = stream + .payout + .filter(|payout| payout.token == token_out); + let declared = match swap.amounts { + Amounts::ExactIn { amount_in, min_amount_out } => DeclaredSwap::from_calldata( + token_in, + token_out, + amount_in, + // The stricter of the two floors: a swap paying the router leaves its own at zero. + min_amount_out.max(payout.map_or(U256::ZERO, |payout| payout.min_amount_out)), + ), + Amounts::ExactOut { amount_out, max_amount_in } => { + DeclaredSwap::from_calldata_exact_out( + token_in, + token_out, + amount_out, + max_amount_in, + ) + } + }; + let recipient = swap + .recipient + .or_else(|| payout.map(|payout| payout.recipient)); + Ok(Some(match recipient { + Some(recipient) => declared.with_recipient(recipient), + None => declared, + })) + } +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{address, Bytes}; + + use super::*; + + const USDC: Address = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + const WETH: Address = address!("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"); + + /// A packed v3 path: `token (fee token)+`, one hop per extra token. + fn v3_path(tokens: &[Address]) -> Bytes { + let mut path = tokens[0].to_vec(); + for token in &tokens[1..] { + path.extend_from_slice(&[0x00, 0x0b, 0xb8]); // the 3000 fee tier + path.extend_from_slice(token.as_slice()); + } + path.into() + } + + /// An `execute` call with one command per input. + fn execute_call(commands: &[u8], inputs: Vec) -> Vec { + execute_0Call { commands: commands.to_vec().into(), inputs, deadline: U256::from(1_u64) } + .abi_encode() + } + + /// A `V3_SWAP_EXACT_IN` input: recipient, amount in, floor, path, payer flag. + fn v3_input(recipient: Address, amount_in: u64, floor: u64, tokens: &[Address]) -> Bytes { + use alloy::sol_types::SolValue; + (recipient, U256::from(amount_in), U256::from(floor), v3_path(tokens), true) + .abi_encode_params() + .into() + } + + /// A `V2_SWAP_EXACT_IN` input, whose path is an `address[]`. + fn v2_input(recipient: Address, amount_in: u64, floor: u64, tokens: &[Address]) -> Bytes { + use alloy::sol_types::SolValue; + (recipient, U256::from(amount_in), U256::from(floor), tokens.to_vec(), true) + .abi_encode_params() + .into() + } + + fn terms(input: &[u8]) -> Option { + Uniswap + .declared(input, &[]) + .ok() + .flatten() + } + + #[test] + fn test_selectors_against_the_deployed_router() { + // Both overloads observed in live traffic: with a deadline and without. + assert_eq!(execute_0Call::SELECTOR, [0x35, 0x93, 0x56, 0x4c]); + assert_eq!(execute_1Call::SELECTOR, [0x24, 0x85, 0x6b, 0xc3]); + } + + #[test] + fn test_deadline_free_overload_reads_the_same_stream() { + use alloy::sol_types::SolCall; + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_1Call { + commands: vec![V3_SWAP_EXACT_IN].into(), + inputs: vec![v3_input(trader, 100_000_000, 5, &[USDC, WETH])], + } + .abi_encode(); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_in, Some(U256::from(100_000_000u64))); + } + + #[test] + fn test_v3_exact_in_single_hop() { + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_call( + &[V3_SWAP_EXACT_IN], + vec![v3_input(trader, 100_000_000, 5, &[USDC, WETH])], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_in, Some(U256::from(100_000_000u64))); + assert_eq!(declared.min_amount_out, Some(U256::from(5u64))); + assert_eq!(declared.output_recipient, Some(trader)); + // Calldata states no settled output. + assert_eq!(declared.amount_out, None); + } + + #[test] + fn test_v3_exact_in_multi_hop_reads_the_path_ends() { + let trader = address!("0x000000000000000000000000000000000000dead"); + let mid = address!("0x6b175474e89094c44da98b954eedeac495271d0f"); + let call = + execute_call(&[V3_SWAP_EXACT_IN], vec![v3_input(trader, 1_000, 1, &[USDC, mid, WETH])]); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + } + + #[test] + fn test_v2_exact_in() { + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = + execute_call(&[V2_SWAP_EXACT_IN], vec![v2_input(trader, 2_000, 7, &[USDC, WETH])]); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_in, Some(U256::from(2_000u64))); + } + + #[test] + fn test_unwrap_makes_the_output_native() { + // `V3_IN UNWRAP` — the path buys WETH, the trader is paid native ETH. + let router = Address::with_last_byte(2); + let call = execute_call( + &[V3_SWAP_EXACT_IN, UNWRAP_WETH], + vec![v3_input(router, 100, 1, &[USDC, WETH]), Bytes::default()], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, Address::ZERO); + } + + #[test] + fn test_wrap_makes_the_input_native() { + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_call( + &[WRAP_ETH, V3_SWAP_EXACT_IN], + vec![Bytes::default(), v3_input(trader, 100, 1, &[WETH, USDC])], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, Address::ZERO); + assert_eq!(declared.token_out, USDC); + } + + /// An `UNWRAP_WETH` input: recipient, then the floor it enforces on the payout. + fn unwrap_input(recipient: Address, amount_min: u64) -> Bytes { + use alloy::sol_types::SolValue; + (recipient, U256::from(amount_min)) + .abi_encode_params() + .into() + } + + /// A `SWEEP` input: token, recipient, then the floor. + fn sweep_input(token: Address, recipient: Address, amount_min: u64) -> Bytes { + use alloy::sol_types::SolValue; + (token, recipient, U256::from(amount_min)) + .abi_encode_params() + .into() + } + + #[test] + fn test_payout_names_the_trader_the_swap_left_as_a_sentinel() { + // The live shape that produced five records claiming a bundler's gas refund as the settled + // output: `V3_SWAP_EXACT_IN(ADDRESS_THIS, floor 0) UNWRAP_WETH(trader, real floor) SWEEP`. + // The floor and the address both live on the payout command. + let trader = address!("0x541a02f8685db041ba872bc5c0bb336377b8be35"); + let call = execute_call( + &[V3_SWAP_EXACT_IN, UNWRAP_WETH, SWEEP], + vec![ + v3_input(Address::with_last_byte(2), 1_736_236_160, 0, &[USDC, WETH]), + unwrap_input(trader, 693_216_921), + sweep_input(Address::ZERO, trader, 0), + ], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, Address::ZERO); + assert_eq!(declared.output_recipient, Some(trader)); + // Without this the recovered output is whatever the transaction sender happened to + // receive, with a zero floor to reject it. + assert_eq!(declared.min_amount_out, Some(U256::from(693_216_921u64))); + } + + #[test] + fn test_sweep_names_a_token_payout() { + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_call( + &[V3_SWAP_EXACT_IN, SWEEP], + vec![ + v3_input(Address::with_last_byte(2), 1_000, 0, &[WETH, USDC]), + sweep_input(USDC, trader, 990), + ], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.output_recipient, Some(trader)); + assert_eq!(declared.min_amount_out, Some(U256::from(990u64))); + } + + #[test] + fn test_payout_of_another_token_is_ignored() { + // A sweep of leftover input, not of the trade's output. + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_call( + &[V3_SWAP_EXACT_IN, SWEEP], + vec![ + v3_input(Address::with_last_byte(2), 1_000, 0, &[WETH, USDC]), + sweep_input(WETH, trader, 5), + ], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.output_recipient, None); + assert_eq!(declared.min_amount_out, Some(U256::ZERO)); + } + + #[test] + fn test_the_swaps_own_recipient_and_floor_win() { + // A swap that names the trader directly does not need the payout command, and the stricter + // of the two floors is kept. + let trader = address!("0x000000000000000000000000000000000000dead"); + let other = address!("0x00000000000000000000000000000000000000ff"); + let call = execute_call( + &[V3_SWAP_EXACT_IN, SWEEP], + vec![v3_input(trader, 1_000, 995, &[WETH, USDC]), sweep_input(USDC, other, 5)], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.output_recipient, Some(trader)); + assert_eq!(declared.min_amount_out, Some(U256::from(995u64))); + } + + #[test] + fn test_payout_before_the_swap_is_not_the_output() { + // A sweep that runs before the swap is clearing a previous balance. + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_call( + &[SWEEP, V3_SWAP_EXACT_IN], + vec![ + sweep_input(USDC, trader, 900), + v3_input(Address::with_last_byte(2), 1_000, 0, &[WETH, USDC]), + ], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.output_recipient, None); + assert_eq!(declared.min_amount_out, Some(U256::ZERO)); + } + + #[test] + fn test_router_sentinel_leaves_the_recipient_unset() { + // ADDRESS_THIS means the router holds the output until a later SWEEP forwards it, so + // there is no address here whose receipt is the trade's output. + let call = execute_call( + &[V3_SWAP_EXACT_IN], + vec![v3_input(Address::with_last_byte(2), 100, 1, &[USDC, WETH])], + ); + assert_eq!(terms(&call).unwrap().output_recipient, None); + } + + #[test] + fn test_msg_sender_sentinel_leaves_the_recipient_unset() { + let call = execute_call( + &[V3_SWAP_EXACT_IN], + vec![v3_input(Address::with_last_byte(1), 100, 1, &[USDC, WETH])], + ); + assert_eq!(terms(&call).unwrap().output_recipient, None); + } + + sol! { + /// v4's pool identity: the two currencies sorted, so `zeroForOne` names the sold side. + struct PoolKey { + address currency0; + address currency1; + uint24 fee; + int24 tickSpacing; + address hooks; + } + + /// `IV4Router.ExactInputSingleParams`. Encoding through `sol!` checks the reader's + /// positional reads against alloy's own encoder. + struct ExactInputSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 amountIn; + uint128 amountOutMinimum; + bytes hookData; + } + + /// `PathKey` — one hop of a multi-hop route. It ends in `bytes hookData`, so a `PathKey[]` + /// element sits behind its own offset. + struct PathKey { + address intermediateCurrency; + uint24 fee; + int24 tickSpacing; + address hooks; + bytes hookData; + } + + /// `IV4Router.ExactInputParams` as Universal Router v2 encodes it: a four-word head. + struct ExactInputParams { + address currencyIn; + PathKey[] path; + uint128 amountIn; + uint128 amountOutMinimum; + } + + /// `IV4Router.ExactOutputParams` — the mirror of `ExactInputParams`: the struct names the + /// output currency and the path runs backwards from it. + struct ExactOutputParams { + address currencyOut; + PathKey[] path; + uint128 amountOut; + uint128 amountInMaximum; + } + + /// The same params as Universal Router 2.1.1 encodes them. A per-hop `minHopPricesX36` + /// array lands between the path and the amounts, making a five-word head and moving both + /// amounts one word later. + struct ExactInputParamsV211 { + address currencyIn; + PathKey[] path; + uint256[] minHopPricesX36; + uint128 amountIn; + uint128 amountOutMinimum; + } + } + + /// One hop of a multi-hop route, at the 3000 fee tier. + fn path_key(token: Address, hook_data: Bytes) -> PathKey { + PathKey { + intermediateCurrency: token, + fee: alloy::primitives::Uint::<24, 1>::from(3000u32), + tickSpacing: alloy::primitives::Signed::<24, 1>::try_from(60i32).unwrap(), + hooks: Address::ZERO, + hookData: hook_data, + } + } + + /// One `SWAP_EXACT_IN` params blob in Universal Router v2's layout. + fn v4_exact_in(currency_in: Address, hops: &[Address], amount_in: u128, floor: u128) -> Bytes { + use alloy::sol_types::SolValue; + ExactInputParams { + currencyIn: currency_in, + path: hops + .iter() + .map(|token| path_key(*token, Bytes::default())) + .collect(), + amountIn: amount_in, + amountOutMinimum: floor, + } + .abi_encode() + .into() + } + + /// One `SWAP_EXACT_OUT` params blob. `currency_out` is the struct's own currency and the path + /// runs backwards from it, so `hops[0]` names the input token. + fn v4_exact_out( + currency_out: Address, + hops: &[Address], + amount_out: u128, + ceiling: u128, + ) -> Bytes { + use alloy::sol_types::SolValue; + ExactOutputParams { + currencyOut: currency_out, + path: hops + .iter() + .map(|token| path_key(*token, Bytes::default())) + .collect(), + amountOut: amount_out, + amountInMaximum: ceiling, + } + .abi_encode() + .into() + } + + /// The same blob in Universal Router 2.1.1's layout. + fn v4_exact_in_211( + currency_in: Address, + hops: &[Address], + amount_in: u128, + floor: u128, + ) -> Bytes { + use alloy::sol_types::SolValue; + ExactInputParamsV211 { + currencyIn: currency_in, + path: hops + .iter() + .map(|token| path_key(*token, Bytes::default())) + .collect(), + minHopPricesX36: vec![U256::from(1u64); hops.len()], + amountIn: amount_in, + amountOutMinimum: floor, + } + .abi_encode() + .into() + } + + /// A `V4_SWAP` command input: `abi.encode(bytes actions, bytes[] params)`. + fn v4_input(actions: &[u8], params: Vec) -> Bytes { + use alloy::sol_types::SolValue; + (Bytes::from(actions.to_vec()), params) + .abi_encode_params() + .into() + } + + /// One `SWAP_EXACT_IN_SINGLE` params blob. + fn v4_exact_in_single( + currency0: Address, + currency1: Address, + zero_for_one: bool, + amount_in: u128, + floor: u128, + ) -> Bytes { + use alloy::sol_types::SolValue; + ExactInputSingleParams { + poolKey: PoolKey { + currency0, + currency1, + fee: alloy::primitives::Uint::<24, 1>::from(3000u32), + tickSpacing: alloy::primitives::Signed::<24, 1>::try_from(60i32).unwrap(), + hooks: Address::ZERO, + }, + zeroForOne: zero_for_one, + amountIn: amount_in, + amountOutMinimum: floor, + hookData: Bytes::default(), + } + .abi_encode() + .into() + } + + /// The action stream a live v4 swap carries: swap, settle the input, take the output. + const V4_SETTLE_ALL: u8 = 0x0c; + const V4_TAKE_ALL: u8 = 0x0f; + + #[test] + fn test_v4_exact_in_single_zero_for_one() { + // currency0 sold for currency1. + let call = execute_call( + &[V4_SWAP], + vec![v4_input( + &[V4_SWAP_EXACT_IN_SINGLE, V4_SETTLE_ALL, V4_TAKE_ALL], + vec![ + v4_exact_in_single(USDC, WETH, true, 100_000_000, 5), + Bytes::default(), + Bytes::default(), + ], + )], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_in, Some(U256::from(100_000_000u64))); + assert_eq!(declared.min_amount_out, Some(U256::from(5u64))); + // A later TAKE pays the output out, so there is no recipient in the swap params. + assert_eq!(declared.output_recipient, None); + } + + #[test] + fn test_v4_exact_in_single_one_for_zero() { + // The same pool, sold the other way: currency1 in, currency0 out. + let call = execute_call( + &[V4_SWAP], + vec![v4_input( + &[V4_SWAP_EXACT_IN_SINGLE, V4_SETTLE_ALL, V4_TAKE_ALL], + vec![ + v4_exact_in_single(USDC, WETH, false, 2_000, 7), + Bytes::default(), + Bytes::default(), + ], + )], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, WETH); + assert_eq!(declared.token_out, USDC); + } + + #[test] + fn test_v4_native_currency_needs_no_translation() { + // v4 names native ETH as the zero address directly, with no wrapping, which is already + // hindsight's convention — so no WRAP_ETH appears and nothing is rewritten. + let call = execute_call( + &[V4_SWAP], + vec![v4_input( + &[V4_SWAP_EXACT_IN_SINGLE, V4_SETTLE_ALL, V4_TAKE_ALL], + vec![ + v4_exact_in_single(Address::ZERO, USDC, true, 6_340_000, 1), + Bytes::default(), + Bytes::default(), + ], + )], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, Address::ZERO); + assert_eq!(declared.token_out, USDC); + } + + #[test] + fn test_v4_exact_out_single_states_the_output_and_bounds_the_input() { + // `ExactOutputSingleParams` puts the two amounts where the exact-input struct puts them, + // so the same read serves both — with their meanings swapped. + let call = execute_call( + &[V4_SWAP], + vec![v4_input( + &[V4_SWAP_EXACT_OUT_SINGLE, V4_SETTLE_ALL, V4_TAKE_ALL], + vec![ + v4_exact_in_single(USDC, WETH, true, 500, 1_000), + Bytes::default(), + Bytes::default(), + ], + )], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_out, Some(U256::from(500u64))); + assert_eq!(declared.max_amount_in, Some(U256::from(1_000u64))); + // The amount spent is not in the calldata: the caller recovers it from what was paid. + assert_eq!(declared.amount_in, None); + assert_eq!(declared.min_amount_out, None); + } + + #[test] + fn test_v4_exact_out_multi_hop_reads_the_path_backwards() { + // `_swapExactOutput` walks the path backwards from `currencyOut`, so `path[0]` names the + // input and the struct's own currency is the output. + let mid = address!("0x6b175474e89094c44da98b954eedeac495271d0f"); + let call = execute_call( + &[V4_SWAP], + vec![v4_input( + &[V4_SWAP_EXACT_OUT, V4_SETTLE_ALL], + vec![v4_exact_out(WETH, &[USDC, mid], 500, 1_000), Bytes::default()], + )], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_out, Some(U256::from(500u64))); + assert_eq!(declared.max_amount_in, Some(U256::from(1_000u64))); + assert_eq!(declared.amount_in, None); + } + + #[test] + fn test_v3_exact_out_reads_the_reversed_path() { + // Uniswap encodes an exact-output v3 path output-first, so the path's first token is the + // one the trader receives. + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = + execute_call(&[V3_SWAP_EXACT_OUT], vec![v3_input(trader, 500, 1_000, &[WETH, USDC])]); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_out, Some(U256::from(500u64))); + assert_eq!(declared.max_amount_in, Some(U256::from(1_000u64))); + assert_eq!(declared.output_recipient, Some(trader)); + } + + #[test] + fn test_v2_exact_out_path_runs_input_to_output() { + // A v2 path is never reversed, whichever side is fixed. + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = + execute_call(&[V2_SWAP_EXACT_OUT], vec![v2_input(trader, 500, 1_000, &[USDC, WETH])]); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_out, Some(U256::from(500u64))); + } + + #[test] + fn test_exact_out_wrap_and_unwrap_keeps_the_token_output() { + // A native-input exact-output swap is funded up to its ceiling and sweeps the remainder + // back, so it carries both commands. The unwrap returns the input, not the output. + let router = Address::with_last_byte(2); + let call = execute_call( + &[WRAP_ETH, V3_SWAP_EXACT_OUT, UNWRAP_WETH], + vec![Bytes::default(), v3_input(router, 500, 1_000, &[USDC, WETH]), Bytes::default()], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, Address::ZERO); + assert_eq!(declared.token_out, USDC); + } + + #[test] + fn test_unwrap_before_the_swap_declines() { + // The live shape of all seven exact-output v4 swaps in the sample: + // `PERMIT2_TRANSFER_FROM UNWRAP_WETH V4_SWAP WRAP_ETH`. The trader pays WETH, the router + // unwraps it for a native-ETH pool, and re-wraps the remainder to return it. The pool + // names native ETH, so the calldata never names the token the trader actually paid. + const PERMIT2_TRANSFER_FROM: u8 = 0x02; + let call = execute_call( + &[PERMIT2_TRANSFER_FROM, UNWRAP_WETH, V4_SWAP, WRAP_ETH], + vec![ + Bytes::default(), + Bytes::default(), + v4_input( + &[V4_SWAP_EXACT_OUT_SINGLE, V4_SETTLE_ALL, V4_TAKE_ALL], + vec![ + v4_exact_in_single(Address::ZERO, USDC, true, 500, 1_000), + Bytes::default(), + Bytes::default(), + ], + ), + Bytes::default(), + ], + ); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_exact_out_unwrap_alone_pays_a_native_output() { + // No wrap, so the unwrap is what pays the trader. + let router = Address::with_last_byte(2); + let call = execute_call( + &[V3_SWAP_EXACT_OUT, UNWRAP_WETH], + vec![v3_input(router, 500, 1_000, &[WETH, USDC]), Bytes::default()], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, Address::ZERO); + } + + #[test] + fn test_zero_exact_output_declined() { + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = + execute_call(&[V3_SWAP_EXACT_OUT], vec![v3_input(trader, 0, 1_000, &[WETH, USDC])]); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_v4_multi_hop_reads_the_path_ends() { + let mid = address!("0x6b175474e89094c44da98b954eedeac495271d0f"); + let call = execute_call( + &[V4_SWAP], + vec![v4_input( + &[V4_SWAP_EXACT_IN, V4_SETTLE_ALL, V4_TAKE_ALL], + vec![ + v4_exact_in(USDC, &[mid, WETH], 100_000_000, 5), + Bytes::default(), + Bytes::default(), + ], + )], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_in, Some(U256::from(100_000_000u64))); + assert_eq!(declared.min_amount_out, Some(U256::from(5u64))); + // A later TAKE pays the output out, so there is no recipient in the swap params. + assert_eq!(declared.output_recipient, None); + } + + #[test] + fn test_v4_multi_hop_211_layout_reads_the_same_amounts() { + // The same route, encoded with `minHopPricesX36` before the amounts. Reading the head's + // last two words finds them where a fixed position would read the path offset instead. + let mid = address!("0x6b175474e89094c44da98b954eedeac495271d0f"); + let call = execute_call( + &[V4_SWAP], + vec![v4_input( + &[V4_SWAP_EXACT_IN, V4_SETTLE_ALL, V4_TAKE_ALL], + vec![ + v4_exact_in_211(USDC, &[mid, WETH], 100_000_000, 5), + Bytes::default(), + Bytes::default(), + ], + )], + ); + let declared = terms(&call).unwrap(); + assert_eq!(declared.token_in, USDC); + assert_eq!(declared.token_out, WETH); + assert_eq!(declared.amount_in, Some(U256::from(100_000_000u64))); + assert_eq!(declared.min_amount_out, Some(U256::from(5u64))); + } + + /// A live Universal Router transaction's full calldata. + fn fixture(text: &str) -> Vec { + let text = text.trim(); + alloy::hex::decode(text.strip_prefix("0x").unwrap_or(text)).unwrap() + } + + #[test] + fn test_v4_multi_hop_live_universal_router_v2() { + // Two hops, each carrying 97 bytes of hook data, so the hops sit at unequal offsets and + // the second cannot be found by striding from the first. + let declared = + terms(&fixture(include_str!("fixtures/uniswap_v4_multi_hop_input.txt"))).unwrap(); + assert_eq!(declared.token_in, address!("0x19640000000ba88d36206beb10d0e86011c8d08c")); + assert_eq!(declared.token_out, address!("0x1223334444a7466fbf985b14e1f4edaf3883bca6")); + assert_eq!(declared.amount_in, Some(U256::from(27_636_981_441_190_000_621_809u128))); + assert_eq!(declared.min_amount_out, Some(U256::from(36_449_320_026_157_160_385_760u128))); + } + + #[test] + fn test_v4_multi_hop_live_universal_router_211() { + // The 2.1.1 layout, live: native ETH in, one hop, a `minHopPricesX36` array before the + // amounts. + let declared = + terms(&fixture(include_str!("fixtures/uniswap_v4_multi_hop_211_input.txt"))).unwrap(); + assert_eq!(declared.token_in, Address::ZERO); + assert_eq!(declared.token_out, address!("0x651e5ea84e2c8ef30ddbf62d716fb2bf37535ffe")); + assert_eq!(declared.amount_in, Some(U256::from(1_000_000_000_000_000u64))); + assert_eq!(declared.min_amount_out, Some(U256::from(90_445_718_574_559_218_595u128))); + } + + #[test] + fn test_round_trip_path_declined() { + // USDC to USDC: two of the four sampled live multi-hop v4 swaps were this. A bot cycling + // a pool, not a trade a re-solve can price. + let call = execute_call( + &[V4_SWAP], + vec![v4_input( + &[V4_SWAP_EXACT_IN, V4_SETTLE_ALL], + vec![v4_exact_in(USDC, &[WETH, USDC], 1_000, 1), Bytes::default()], + )], + ); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_v4_multi_hop_without_hops_declined() { + // An empty path names no output token. + let call = execute_call( + &[V4_SWAP], + vec![v4_input(&[V4_SWAP_EXACT_IN], vec![v4_exact_in(USDC, &[], 1_000, 1)])], + ); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_v4_multi_hop_malformed_params_declined() { + // An empty params blob has no head to read. + let call = execute_call( + &[V4_SWAP], + vec![v4_input(&[V4_SWAP_EXACT_IN, V4_SETTLE_ALL], vec![Bytes::default(); 2])], + ); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_v3_and_v4_in_one_stream_declined() { + // A route can start in v3 and finish in v4, so neither leg alone is the trade. + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_call( + &[V3_SWAP_EXACT_IN, V4_SWAP], + vec![ + v3_input(trader, 100, 1, &[USDC, WETH]), + v4_input( + &[V4_SWAP_EXACT_IN_SINGLE, V4_TAKE_ALL], + vec![v4_exact_in_single(WETH, USDC, true, 100, 1), Bytes::default()], + ), + ], + ); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_v4_stream_with_no_swap_declined() { + // Settle/take only: no trade in the stream. + let call = execute_call( + &[V4_SWAP], + vec![v4_input(&[V4_SETTLE_ALL, V4_TAKE_ALL], vec![Bytes::default(); 2])], + ); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_split_route_declined() { + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_call( + &[V3_SWAP_EXACT_IN, V3_SWAP_EXACT_IN], + vec![v3_input(trader, 60, 1, &[USDC, WETH]), v3_input(trader, 40, 1, &[USDC, WETH])], + ); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_no_swap_command_declined() { + // Permit-only or sweep-only streams carry no trade. + assert!(terms(&execute_call(&[WRAP_ETH], vec![Bytes::default()])).is_none()); + assert!(terms(&execute_call(&[], vec![])).is_none()); + } + + #[test] + fn test_zero_amount_declined() { + let trader = address!("0x000000000000000000000000000000000000dead"); + let call = execute_call(&[V3_SWAP_EXACT_IN], vec![v3_input(trader, 0, 1, &[USDC, WETH])]); + assert!(terms(&call).is_none()); + } + + #[test] + fn test_garbage_and_truncated_input_declined() { + assert!(terms(&[]).is_none()); + assert!(terms(&[0xde, 0xad, 0xbe, 0xef]).is_none()); + let trader = address!("0x000000000000000000000000000000000000dead"); + let full = execute_call(&[V3_SWAP_EXACT_IN], vec![v3_input(trader, 100, 1, &[USDC, WETH])]); + assert!(terms(&full[..full.len() / 2]).is_none()); + } + + #[test] + fn test_malformed_v3_path_declined() { + use alloy::sol_types::SolValue; + // A path that is not `token (fee token)+` cannot name a pair. + let trader = address!("0x000000000000000000000000000000000000dead"); + let stub: Bytes = + (trader, U256::from(100u64), U256::from(1u64), Bytes::from(vec![0u8; 25]), true) + .abi_encode_params() + .into(); + assert!(terms(&execute_call(&[V3_SWAP_EXACT_IN], vec![stub])).is_none()); + } +} diff --git a/tools/hindsight/src/decoder/solvers/zeroex.rs b/tools/hindsight/src/decoder/solvers/zeroex.rs index 4a9e7c434..001e969fe 100644 --- a/tools/hindsight/src/decoder/solvers/zeroex.rs +++ b/tools/hindsight/src/decoder/solvers/zeroex.rs @@ -13,18 +13,22 @@ //! A bare Settler entry (no `AllowanceHolder` wrapper) never occurred in the sample, and — unlike //! `AllowedSlippage`, which is read the same way either way — has no calldata field that reliably //! carries `token_in`/`amount_in`: Settler's `actions` array is heterogeneous per liquidity source, -//! so scanning it for an input-token address would be a guess, not a decode. `swap_intent` for a +//! so scanning it for an input-token address would be a guess, not a decode. `declared_swap` for a //! bare entry is declined rather than guessed, per the "no dead code, no guessing" rule; if bare //! entries turn out to matter, the `token_in` question needs its own investigation, not a shortcut //! here. use alloy::{ primitives::{Address, U256}, + rpc::types::Log, sol, sol_types::SolCall, }; -use crate::decoder::solvers::{SolverKnowledge, SwapIntent}; +use crate::decoder::{ + solvers::{normalize_native, DeclaredSwap, SolverDecoder}, + veto::Veto, +}; sol! { /// `IAllowanceHolder.exec` — Relay's 0x flow always enters through this wrapper before @@ -56,19 +60,6 @@ sol! { external; } -/// 0x's own native-ETH sentinel — distinct from hindsight's `Address::ZERO` convention, so it is -/// normalized on the way out (matching `KyberSwap`'s equivalent). -const ZEROEX_NATIVE: Address = - alloy::primitives::address!("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"); - -fn normalize_native(token: Address) -> Address { - if token == ZEROEX_NATIVE { - Address::ZERO - } else { - token - } -} - /// Settler's own terms, decoded from an `execute` call regardless of how it was reached (wrapped /// in `AllowanceHolder.exec` or, if it ever occurs, called directly). struct SettlerTerms { @@ -100,47 +91,51 @@ fn decode_execute(input: &[u8]) -> Option { pub(crate) struct ZeroEx; -impl SolverKnowledge for ZeroEx { +impl SolverDecoder for ZeroEx { /// The trader's swap terms from `AllowanceHolder.exec`'s own parameters (`token`/`amount`, /// the input side) and the wrapped `execute` call's `AllowedSlippage` (`buyToken`/ /// `minAmountOut`, the output side). `minAmountOut` is passed through as-is, including a /// legitimate zero (Settler's per-action slippage checks can leave the top-level floor at - /// zero) — the intent is still worth recording, and the fillable/margin judgment already - /// treats a zero floor sanely (trivially fillable, no margin to compute). `amount_in_hint` is - /// unused: `AllowanceHolder`'s own parameter is the real amount, not a value to locate a field - /// by. - fn swap_intent(&self, input: &[u8], _amount_in_hint: Option) -> Option { - let call = execCall::abi_decode(input).ok()?; - if call.amount.is_zero() { - return None; + /// zero). A zero floor costs nothing downstream: `declared::recover_output` compares the + /// recovered output against it, and every output clears zero. + /// + /// `amount` is the allowance the taker authorises, which is normally the exact input — it + /// matched the netted `amount_in` on both audited trades. An unlimited approval passes + /// `U256::MAX` instead, which states no amount at all, so the transaction goes to netting. + /// Reading that sentinel as the amount spent asked the re-solve to sell `2^256 - 1` USDC and + /// scored the answer as a win worth tens of millions of dollars. + fn declared(&self, input: &[u8], _logs: &[Log]) -> Result, Veto> { + let Ok(call) = execCall::abi_decode(input) else { return Ok(None) }; + if call.amount.is_zero() || call.amount == U256::MAX { + return Ok(None); } - let terms = decode_execute(&call.data)?; - let intent = SwapIntent::new( + let Some(terms) = decode_execute(&call.data) else { return Ok(None) }; + // Settler's `AllowedSlippage.recipient` — the address whose receipt anchors the settled + // amount, same as Fly/KyberSwap. + let intent = DeclaredSwap::from_calldata( normalize_native(call.token), terms.buy_token, call.amount, terms.min_amount_out, - ); - Some(match terms.declared_quote { + ) + .with_recipient(terms.recipient); + Ok(Some(match terms.declared_quote { Some(quote) => intent.with_quote(quote, None), None => intent, - }) - } - - /// Settler's `AllowedSlippage.recipient` — the address whose receipt `RelayCalldata` anchors - /// the settled amount on, same as Fly/KyberSwap. Tried both wrapped (`AllowanceHolder.exec`) - /// and bare (`execute` called directly): unlike `swap_intent`, the recipient needs nothing - /// `AllowanceHolder` adds, so a bare entry still resolves even though its `token_in` cannot. - fn output_recipient(&self, input: &[u8]) -> Option
{ - if let Ok(call) = execCall::abi_decode(input) { - return decode_execute(&call.data).map(|terms| terms.recipient); - } - decode_execute(input).map(|terms| terms.recipient) + })) } } #[cfg(test)] mod tests { + /// The terms this solver reads from `input`, for tests that only care about the calldata path. + fn terms(input: &[u8]) -> Option { + ZeroEx + .declared(input, &[]) + .ok() + .flatten() + } + use alloy::primitives::address; use super::*; @@ -167,39 +162,37 @@ mod tests { const RELAY_ROUTER: Address = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); #[test] - fn test_real_settled_swap_intent() { - let intent = ZeroEx - .swap_intent(&settled_input(), None) - .unwrap(); + fn test_real_settled_declared_swap() { + let intent = terms(&settled_input()).unwrap(); assert_eq!(intent.token_in, Address::ZERO); // 0x's native-ETH sentinel, normalized assert_eq!(intent.token_out, USDC); - assert_eq!(intent.amount_in, U256::from(214_715_436_309_542_453u64)); - assert_eq!(intent.min_amount_out, U256::from(388_129_000u64)); - assert_eq!(intent.declared_quote(), Some(U256::from(396_058_371u64))); + assert_eq!(intent.amount_in, Some(U256::from(214_715_436_309_542_453u64))); + assert_eq!(intent.min_amount_out, Some(U256::from(388_129_000u64))); + assert_eq!(intent.declared_quote, Some(U256::from(396_058_371u64))); } #[test] fn test_real_settled_output_recipient() { - assert_eq!(ZeroEx.output_recipient(&settled_input()), Some(RELAY_ROUTER)); + let intent = terms(&settled_input()).unwrap(); + assert_eq!(intent.output_recipient, Some(RELAY_ROUTER)); } #[test] - fn test_real_reverted_swap_intent() { + fn test_real_reverted_declared_swap() { // The reverted trade's terms decode the same way a settled one's do — a revert emits no // logs, so calldata is the only source, and it is read no differently here. - let intent = ZeroEx - .swap_intent(&reverted_input(), None) - .unwrap(); + let intent = terms(&reverted_input()).unwrap(); assert_eq!(intent.token_in, Address::ZERO); assert_eq!(intent.token_out, USDC); - assert_eq!(intent.amount_in, U256::from(2_018_128_791_326_365_345u64)); - assert_eq!(intent.min_amount_out, U256::from(3_643_640_000u64)); - assert_eq!(intent.declared_quote(), Some(U256::from(3_718_000_789u64))); + assert_eq!(intent.amount_in, Some(U256::from(2_018_128_791_326_365_345u64))); + assert_eq!(intent.min_amount_out, Some(U256::from(3_643_640_000u64))); + assert_eq!(intent.declared_quote, Some(U256::from(3_718_000_789u64))); } #[test] fn test_real_reverted_output_recipient() { - assert_eq!(ZeroEx.output_recipient(&reverted_input()), Some(RELAY_ROUTER)); + let intent = terms(&reverted_input()).unwrap(); + assert_eq!(intent.output_recipient, Some(RELAY_ROUTER)); } #[test] @@ -210,9 +203,9 @@ mod tests { } #[test] - fn test_bare_settler_entry_has_no_swap_intent_but_resolves_recipient() { - // A direct `execute` call (no `AllowanceHolder` wrapper): swap_intent has nowhere to read - // token_in/amount_in from, so it declines; output_recipient does not need them. + fn test_bare_settler_entry_declines() { + // A direct `execute` call (no `AllowanceHolder` wrapper): there is nowhere to read + // token_in/amount_in from, so the whole decode declines and netting carries the trade. let call = executeCall { slippage: AllowedSlippage { recipient: RELAY_ROUTER, @@ -223,20 +216,12 @@ mod tests { zidAndAffiliate: alloy::primitives::FixedBytes::default(), }; let input = executeCall::abi_encode(&call); - assert!(ZeroEx - .swap_intent(&input, None) - .is_none()); - assert_eq!(ZeroEx.output_recipient(&input), Some(RELAY_ROUTER)); + assert!(terms(&input).is_none()); } #[test] fn test_garbage_input_declines() { - assert!(ZeroEx - .swap_intent(&[0xde, 0xad, 0xbe, 0xef], None) - .is_none()); - assert!(ZeroEx - .output_recipient(&[0xde, 0xad, 0xbe, 0xef]) - .is_none()); + assert!(terms(&[0xde, 0xad, 0xbe, 0xef]).is_none()); } #[test] @@ -250,9 +235,31 @@ mod tests { data: alloy::primitives::Bytes::new(), }; let input = execCall::abi_encode(&call); - assert!(ZeroEx - .swap_intent(&input, None) - .is_none()); + assert!(terms(&input).is_none()); + } + + #[test] + fn test_unlimited_allowance_rejected() { + // An unlimited approval authorises `U256::MAX` and states no amount. Seven live records + // read it as the amount spent, which asked the re-solve to sell 2^256 - 1 USDC and scored + // the answer as a win worth tens of millions of dollars. + let execute_call = executeCall { + slippage: AllowedSlippage { + recipient: RELAY_ROUTER, + buyToken: USDC, + minAmountOut: U256::from(1_000u64), + }, + actions: vec![], + zidAndAffiliate: alloy::primitives::FixedBytes::default(), + }; + let call = execCall { + operator: RELAY_ROUTER, + token: USDC, + amount: U256::MAX, + target: RELAY_ROUTER, + data: executeCall::abi_encode(&execute_call).into(), + }; + assert!(terms(&execCall::abi_encode(&call)).is_none()); } #[test] @@ -276,15 +283,7 @@ mod tests { data: executeCall::abi_encode(&execute_call).into(), }; let input = execCall::abi_encode(&call); - let intent = ZeroEx - .swap_intent(&input, None) - .unwrap(); - assert_eq!(intent.min_amount_out, U256::ZERO); - } - - #[test] - fn test_native_eth_sentinel_normalized() { - assert_eq!(normalize_native(ZEROEX_NATIVE), Address::ZERO); - assert_eq!(normalize_native(USDC), USDC); + let intent = terms(&input).unwrap(); + assert_eq!(intent.min_amount_out, Some(U256::ZERO)); } } diff --git a/tools/hindsight/src/decoder/test_utils.rs b/tools/hindsight/src/decoder/test_utils.rs index c57909fe7..4922d9220 100644 --- a/tools/hindsight/src/decoder/test_utils.rs +++ b/tools/hindsight/src/decoder/test_utils.rs @@ -7,7 +7,7 @@ use alloy::{ sol_types::SolEvent, }; -use crate::decoder::transfer_ledger::{NetSwap, Transfer}; +use crate::decoder::transfer_ledger::{SettledSwap, Transfer}; /// The canonical Permit2 deployment, for fixtures exercising the registry's infrastructure set. pub(crate) const PERMIT2: Address = address!("0x000000000022d473030f116ddee9f6b43ac78ba3"); @@ -26,14 +26,16 @@ pub(crate) fn tx_hash(n: u8) -> TxHash { TxHash::from(bytes) } -/// A `NetSwap` literal, for concise assertions. +/// A `SettledSwap` literal for concise assertions, tracked on `addr(1)`. Tests that care about +/// the trader assert `tracked` themselves. pub(crate) fn swap( token_in: Address, amount_in: u64, token_out: Address, amount_out: u64, -) -> NetSwap { - NetSwap { +) -> SettledSwap { + SettledSwap { + tracked: addr(1), token_in, amount_in: U256::from(amount_in), token_out, diff --git a/tools/hindsight/src/decoder/trace.rs b/tools/hindsight/src/decoder/trace.rs index 5eb2f7654..6392150a0 100644 --- a/tools/hindsight/src/decoder/trace.rs +++ b/tools/hindsight/src/decoder/trace.rs @@ -1,32 +1,55 @@ +use std::collections::{HashMap, VecDeque}; + use alloy::{ + eips::BlockNumberOrTag, primitives::{Address, TxHash, U256}, providers::{ext::DebugApi, Provider}, - rpc::types::trace::geth::{CallConfig, CallFrame, GethDebugTracingOptions, GethTrace}, + rpc::types::trace::{ + common::TraceResult, + geth::{CallConfig, CallFrame, GethDebugTracingOptions, GethTrace}, + }, }; use anyhow::Context; +use tracing::warn; use crate::decoder::registry::Registry; -/// Fetch the callTracer root frame for a transaction. +/// Fetch the callTracer root frame of every transaction in a block, keyed by transaction hash — +/// one `debug_traceBlockByNumber` call instead of one `debug_traceTransaction` per transaction. /// -/// The trace is the only place native ETH transfers and the internal -/// solver call appear — neither emits a log. -pub(crate) async fn fetch_trace( +/// The trace is the only place native ETH transfers and the internal solver call appear — neither +/// emits a log. A transaction the tracer could not process is dropped from the map with a warning +/// (its trades are lost, not the block's); a failure of the whole call is the block's error. +pub(crate) async fn fetch_block_traces( provider: &P, - tx_hash: TxHash, -) -> anyhow::Result { + block_number: u64, +) -> anyhow::Result> { let options = GethDebugTracingOptions::call_tracer(CallConfig::default()); - let trace = provider - .debug_trace_transaction(tx_hash, options) + let traces = provider + .debug_trace_block_by_number(BlockNumberOrTag::Number(block_number), options) .await .with_context(|| { - format!("failed to trace {tx_hash} (does the RPC support debug_traceTransaction?)") + format!( + "failed to trace block {block_number} \ + (does the RPC support debug_traceBlockByNumber?)" + ) })?; - let GethTrace::CallTracer(root) = trace else { - anyhow::bail!("expected callTracer output for {tx_hash}"); - }; - Ok(root) + let mut roots = HashMap::with_capacity(traces.len()); + for trace in traces { + match trace { + TraceResult::Success { result: GethTrace::CallTracer(root), tx_hash: Some(hash) } => { + roots.insert(hash, root); + } + TraceResult::Success { result, tx_hash } => { + warn!(?tx_hash, ?result, "expected callTracer output in the block trace"); + } + TraceResult::Error { error, tx_hash } => { + warn!(?tx_hash, error, "block trace failed for one transaction"); + } + } + } + Ok(roots) } /// Walk the call frames, collecting native ETH value transfers. @@ -57,30 +80,18 @@ fn transfers_value(call_type: &str) -> bool { matches!(call_type, "CALL" | "CALLCODE" | "CREATE" | "CREATE2" | "SELFDESTRUCT") } -/// Gas consumed by the settled route inside a venue-wrapped transaction (Relay, `MetaMask`), in -/// gas units. +/// The outermost call frame into a known solver, skipping reverted frames (and their subtrees), +/// which settle nothing. /// -/// The venue's own gas — fee transfers, forwarding, the base transaction cost — is charged -/// whichever router the venue picks, so like the venue fee it is excluded from the comparison. Each -/// trace frame's `gas_used` includes its whole subtree, so the call into the solver carries the -/// full routing cost. Prefers the first call into a known solver; falls back to the most -/// gas-consuming direct child, since in a wrapped transaction the routing work dwarfs the -/// bookkeeping calls. `None` when no usable frame exists — the caller then skips the gas -/// deduction rather than guess. -pub(crate) fn route_gas(root: &CallFrame, registry: &Registry) -> Option { - if let Some(frame) = find_solver_frame(root, registry) { - return Some(frame.gas_used); - } - root.calls - .iter() - .filter(|child| child.error.is_none()) - .map(|child| child.gas_used) - .max() - .filter(|gas| !gas.is_zero()) -} - -/// Depth-first search for the first call frame into a known solver, skipping reverted frames -/// (and their subtrees), which settle nothing. +/// Breadth-first, so a solver that is a direct child wins over one buried deeper in an earlier +/// branch. Depth is what decides which frame settled the trade: the outer one called the inner +/// one, so the outer one owns the trade and the inner one is a step in its route. A depth-first +/// walk returned whichever solver frame it reached first going down — the same frame on a single +/// chain of calls, the wrong one when two frames sit on separate branches. +/// +/// Only meaningful when the transaction has one leg. A transaction that entered a solver router +/// several times independently has no single settling frame, and `declared::declared_flow` declines +/// it on [`solver_frames`] before asking this — so the walk order decides a label, never an amount. /// /// The one walk serves both questions asked of a trace: *who* settled the swap (the frame's /// `to`, for attribution) and *what the route cost* (the frame's `gas_used`, for gas @@ -90,18 +101,37 @@ pub(crate) fn find_solver_frame<'a>( frame: &'a CallFrame, registry: &Registry, ) -> Option<&'a CallFrame> { - if frame.error.is_some() { - return None; - } - if let Some(to) = frame.to { - if registry.is_solver(to) { - return Some(frame); + let mut frames = solver_frames(frame, registry); + frames.truncate(1); + frames.pop() +} + +/// Every outermost call frame into a known solver: the legs of the transaction. A solver called +/// from inside another solver's frame is a step in that solver's route, not a leg of its own, so a +/// matched frame's subtree is not searched. +/// +/// One leg is the normal case. Several means the transaction swapped more than once, so no single +/// leg is the trade and `declared::declared_flow` declines to read one — an arbitrage contract +/// routing several legs through Uniswap's universal router is the shape seen live. This is not a +/// `[batch_settlers]` settlement, which is many signed orders in one transaction and is declined +/// one layer up. +pub(crate) fn solver_frames<'a>(frame: &'a CallFrame, registry: &Registry) -> Vec<&'a CallFrame> { + let mut found = Vec::new(); + let mut queue = VecDeque::from([frame]); + while let Some(frame) = queue.pop_front() { + if frame.error.is_some() { + continue; + } + if frame + .to + .is_some_and(|to| registry.is_solver(to)) + { + found.push(frame); + continue; } + queue.extend(&frame.calls); } - frame - .calls - .iter() - .find_map(|child| find_solver_frame(child, registry)) + found } /// Best guess at an unknown router: the entry point's direct child call that moved the most @@ -179,106 +209,85 @@ mod tests { assert_eq!(out, vec![(from, to, U256::from(1000))]); } - fn with_gas(mut call: CallFrame, gas_used: u64) -> CallFrame { - call.gas_used = U256::from(gas_used); - call - } - #[test] - fn test_route_gas_known_venue() { - // Mirrors the audited Relay tx 0xf25ceafd…: two small wrapper self-calls around the - // KyberSwap router call, whose frame carries the full routing cost. - // - // relay (1,271,689 total) - // ├── relay self-call 15,066 - // ├── kyberswap router 1,067,571 <- the route - // └── relay self-call 18,802 + fn test_find_solver_frame_reverted_frames() { let registry = Registry::ethereum(); - let sender = addr(1); - let relay = addr(2); - let kyber = address!("0x6131b5fae19ea4f9d964eac0408e4408b66337b5"); - - let mut root = with_gas(frame("CALL", sender, relay, 0), 1_271_689); - root.calls = vec![ - with_gas(frame("CALL", relay, relay, 0), 15_066), - with_gas(frame("CALL", relay, kyber, 0), 1_067_571), - with_gas(frame("CALL", relay, relay, 0), 18_802), - ]; - - assert_eq!(route_gas(&root, ®istry), Some(U256::from(1_067_571u64))); + let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); + + let mut reverted = frame("CALL", addr(2), oneinch, 0); + reverted.error = Some("execution reverted".to_string()); + let mut root = frame("CALL", addr(1), addr(2), 0); + root.calls = vec![reverted]; + + assert!(find_solver_frame(&root, ®istry).is_none()); } #[test] - fn test_route_gas_unknown_venue() { - // Unknown venue: no registry match, so the most gas-consuming child is the route. + fn test_find_solver_frame_prefers_the_shallower_of_two_branches() { + // Two known solvers on separate branches: 1inch is a direct child, 0x sits one level down + // inside the branch that comes first. The direct child settled the trade — the other is a + // step inside someone else's route — so depth decides, not walk order. let registry = Registry::ethereum(); - let client = addr(2); + let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); + let zerox = address!("0x0000000000001ff3684f28c67538d4d072c22734"); + let venue = addr(2); - let mut root = with_gas(frame("CALL", addr(1), client, 0), 500_000); - root.calls = vec![ - with_gas(frame("CALL", client, addr(50), 0), 30_000), - with_gas(frame("CALL", client, addr(51), 0), 400_000), - ]; + let mut first_branch = frame("CALL", venue, addr(3), 0); + first_branch.calls = vec![frame("CALL", addr(3), zerox, 0)]; + let mut root = frame("CALL", addr(1), venue, 0); + root.calls = vec![first_branch, frame("CALL", venue, oneinch, 0)]; - assert_eq!(route_gas(&root, ®istry), Some(U256::from(400_000u64))); + assert_eq!(find_solver_frame(&root, ®istry).and_then(|frame| frame.to), Some(oneinch)); } #[test] - fn test_route_gas_reverted_and_empty() { + fn test_find_solver_frame_takes_the_outer_of_two_nested_solvers() { + // Stacked rather than side by side: the outer solver called the inner one, so the outer + // one owns the trade. This shape already resolved correctly and must keep doing so. let registry = Registry::ethereum(); - let client = addr(2); + let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); + let zerox = address!("0x0000000000001ff3684f28c67538d4d072c22734"); - let mut reverted = with_gas(frame("CALL", client, addr(50), 0), 400_000); - reverted.error = Some("execution reverted".to_string()); - let mut root = with_gas(frame("CALL", addr(1), client, 0), 500_000); - root.calls = vec![reverted]; - assert_eq!(route_gas(&root, ®istry), None); + let mut outer = frame("CALL", addr(2), oneinch, 0); + outer.calls = vec![frame("CALL", oneinch, zerox, 0)]; + let mut root = frame("CALL", addr(1), addr(2), 0); + root.calls = vec![outer]; - let leaf = frame("CALL", addr(1), client, 0); - assert_eq!(route_gas(&leaf, ®istry), None); + assert_eq!(find_solver_frame(&root, ®istry).and_then(|frame| frame.to), Some(oneinch)); } #[test] - fn test_route_gas_real_relay_kyberswap_trace() { - // Real callTracer output of tx 0xf25ceafd… (block 25480207, 39.67 ETH -> USDT via - // Relay + KyberSwap), payload fields stripped. The route's gas is the KyberSwap router - // frame; Relay's wrapper overhead (1,271,689 total) stays out. - let root: CallFrame = - serde_json::from_str(include_str!("fixtures/trace_relay_kyberswap_0xf25ceafd.json")) - .unwrap(); - assert_eq!(root.gas_used, U256::from(1_271_689u64)); - assert_eq!(route_gas(&root, &Registry::ethereum()), Some(U256::from(1_067_571u64))); - } + fn test_find_solver_frame_matches_the_root_before_descending() { + // A transaction sent straight to a router: the root is the solver frame, whatever the + // route below it touches. This is the common case and the walk must not descend past it. + let registry = Registry::ethereum(); + let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); + let zerox = address!("0x0000000000001ff3684f28c67538d4d072c22734"); - #[test] - fn test_route_gas_real_metamask_oneinch_trace() { - // Real callTracer output of tx 0xe815e2b5… (block 25476433, a $3.4k MetaMask swap - // routed via 1inch), payload fields stripped. The 1inch frame sits three levels deep: - // - // metamask router 185,699 - // └── spender 180,406 <- largest child: wrapper, NOT the route - // └── adapter 175,635 (delegatecall) - // ├── 1inch v6 115,795 <- the route - // └── fee wallet 6,329 (MetaMask's fee, correctly excluded) - // - // so the known-venue search must win over the largest-child fallback. - let root: CallFrame = - serde_json::from_str(include_str!("fixtures/trace_metamask_1inch_0xe815e2b5.json")) - .unwrap(); - assert_eq!(root.gas_used, U256::from(185_699u64)); - assert_eq!(route_gas(&root, &Registry::ethereum()), Some(U256::from(115_795u64))); + let mut root = frame("CALL", addr(1), oneinch, 0); + root.calls = vec![frame("CALL", oneinch, zerox, 0)]; + + assert_eq!(find_solver_frame(&root, ®istry).and_then(|frame| frame.to), Some(oneinch)); } #[test] - fn test_find_solver_frame_reverted_frames() { + fn test_find_solver_frame_skips_a_reverted_branch_for_a_deeper_live_one() { + // A reverted frame prunes its whole subtree, so a live solver deeper in another branch is + // still found. Breadth-first must not turn the prune into a stop. let registry = Registry::ethereum(); let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65"); + let venue = addr(2); - let mut reverted = frame("CALL", addr(2), oneinch, 0); + let mut reverted = frame("CALL", venue, addr(3), 0); reverted.error = Some("execution reverted".to_string()); - let mut root = frame("CALL", addr(1), addr(2), 0); - root.calls = vec![reverted]; - - assert!(find_solver_frame(&root, ®istry).is_none()); + reverted.calls = vec![frame("CALL", addr(3), oneinch, 0)]; + let mut live = frame("CALL", venue, addr(4), 0); + live.calls = vec![frame("CALL", addr(4), oneinch, 0)]; + let mut root = frame("CALL", addr(1), venue, 0); + root.calls = vec![reverted, live]; + + let found = find_solver_frame(&root, ®istry).expect("the live branch still matches"); + assert_eq!(found.to, Some(oneinch)); + assert_eq!(found.from, addr(4)); } } diff --git a/tools/hindsight/src/decoder/transfer_ledger.rs b/tools/hindsight/src/decoder/transfer_ledger.rs index 5dda3dd5e..06a0b328e 100644 --- a/tools/hindsight/src/decoder/transfer_ledger.rs +++ b/tools/hindsight/src/decoder/transfer_ledger.rs @@ -49,10 +49,16 @@ pub(crate) fn to_primitive_log(log: &Log) -> PrimitiveLog { PrimitiveLog::new_unchecked(log.address(), log.topics().to_vec(), log.data().data.clone()) } -/// A netted swap: the single token (and amount) that left an address and the -/// single token that came back. Native ETH is `Address::ZERO`. +/// One address's trade: the single token (and amount) that left it and the single token that came +/// back. Native ETH is `Address::ZERO`. +/// +/// Every decode path produces one of these — netting by reading what moved, a declared read from +/// the solver's own logs — and it is the shape the rest of the decoder works on. There is no +/// separate "amounts without an owner" type: whose trade it is has to be known to record it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct NetSwap { +pub(crate) struct SettledSwap { + /// The address whose flow the trade was read from. + pub tracked: Address, pub token_in: Address, pub amount_in: U256, pub token_out: Address, @@ -138,7 +144,7 @@ impl TransferLedger { /// tx `0xd01666df…` on Arbitrum fills 6,164 DAI → 3.2313 WETH to the order's receiver and /// pays the tracked trader 0.00009 WETH; the pair decoded as a $6k win. The output leg must /// therefore itself pass the residue proof — see `is_dust_output`. - pub(crate) fn net_swap(&self, tracked: Address) -> Option { + pub(crate) fn net_swap(&self, tracked: Address) -> Option { let mut amounts_by_token: HashMap = HashMap::new(); for &(token, from, to, value) in &self.transfers { let amounts = amounts_by_token @@ -156,7 +162,7 @@ impl TransferLedger { } } - net_trade(&amounts_by_token) + net_trade(tracked, &amounts_by_token) } /// Every address that sent or received value, ordered for deterministic iteration. @@ -178,54 +184,13 @@ impl TransferLedger { .fold(U256::ZERO, |total, &(_, _, _, value)| total.saturating_add(value)) } - /// Gross total received per token by any of `recipients`, regardless of sender (native ETH - /// keyed by `Address::ZERO`). - pub(crate) fn received_by(&self, recipients: &HashSet
) -> HashMap { - let mut totals: HashMap = HashMap::new(); - if recipients.is_empty() { - return totals; - } - for &(token, _, to, value) in &self.transfers { - if recipients.contains(&to) { - *totals.entry(token).or_default() += value; - } - } - totals - } - - /// Per-token net outflow of the address group: what the group sent minus what it got back, - /// where positive. - pub(crate) fn group_net_sent(&self, group: &HashSet
) -> HashMap { - let (sent, received) = self.group_totals(group); - net_positive(&sent, &received) - } - - /// Per-token net inflow of the address group: what the group received minus what it sent, - /// where positive. - pub(crate) fn group_net_received(&self, group: &HashSet
) -> HashMap { - let (sent, received) = self.group_totals(group); - net_positive(&received, &sent) - } - - /// Aggregated receipts of **pure sinks** — addresses that received value but never sent any - /// in the transaction — as `(recipient, token, total)`. A pool or router always sends - /// something back, so a pure sink is a delivery endpoint (an order's recipient, a payout). - pub(crate) fn sink_receipts(&self) -> Vec<(Address, Address, U256)> { - let mut senders: HashSet
= HashSet::new(); - for &(_, from, _, _) in &self.transfers { - senders.insert(from); - } - let mut received: HashMap<(Address, Address), U256> = HashMap::new(); - for &(token, _, to, value) in &self.transfers { - if !senders.contains(&to) { - *received.entry((to, token)).or_default() += value; - } - } - received - .into_iter() - .filter(|(_, total)| !total.is_zero()) - .map(|((recipient, token), total)| (recipient, token, total)) - .collect() + /// Gross amount of `token` sent by `sender`, regardless of recipient (native ETH is + /// `Address::ZERO`). Zero when the address sent none of it. + pub(crate) fn sent_by_address(&self, sender: Address, token: Address) -> U256 { + self.transfers + .iter() + .filter(|&&(transfer_token, from, _, _)| transfer_token == token && from == sender) + .fold(U256::ZERO, |total, &(_, _, _, value)| total.saturating_add(value)) } /// Totals of `token` paid to pure sinks — addresses that received value in the transaction @@ -253,53 +218,20 @@ impl TransferLedger { .collect() } - /// Gross flow of one token through the transaction, counting every transfer of it in either - /// direction. The denominator of the residue test. - pub(crate) fn token_gross(&self, token: Address) -> U256 { - let mut gross = U256::ZERO; - for &(transferred, _, _, value) in &self.transfers { - if transferred == token { - gross = gross.saturating_add(value); - } + /// Gross total received per token by any of `recipients`, regardless of sender (native ETH + /// keyed by `Address::ZERO`). + pub(crate) fn received_by(&self, recipients: &HashSet
) -> HashMap { + let mut totals: HashMap = HashMap::new(); + if recipients.is_empty() { + return totals; } - gross - } - - /// Gross sent and received per token, summed across the group. - fn group_totals( - &self, - group: &HashSet
, - ) -> (HashMap, HashMap) { - let mut sent: HashMap = HashMap::new(); - let mut received: HashMap = HashMap::new(); - for &(token, from, to, value) in &self.transfers { - if group.contains(&from) { - *sent.entry(token).or_default() += value; - } - if group.contains(&to) { - *received.entry(token).or_default() += value; + for &(token, _, to, value) in &self.transfers { + if recipients.contains(&to) { + *totals.entry(token).or_default() += value; } } - (sent, received) - } -} - -/// Per-token `positive - negative` where positive, over the union of tokens. -fn net_positive( - positive: &HashMap, - negative: &HashMap, -) -> HashMap { - let mut net: HashMap = HashMap::new(); - for (&token, &amount) in positive { - let offset = negative - .get(&token) - .copied() - .unwrap_or_default(); - if amount > offset { - net.insert(token, amount - offset); - } + totals } - net } /// A net leg is residue when its token routed between third parties and the leg is under this @@ -307,7 +239,10 @@ fn net_positive( pub(crate) const RESIDUE_GROSS_RATIO: u64 = 100; /// Net the per-token amounts into a single swap (see `TransferLedger::net_swap`). -fn net_trade(amounts_by_token: &HashMap) -> Option { +fn net_trade( + tracked: Address, + amounts_by_token: &HashMap, +) -> Option { let mut net_sent: HashMap = HashMap::new(); let mut net_received: HashMap = HashMap::new(); @@ -346,7 +281,7 @@ fn net_trade(amounts_by_token: &HashMap) -> Option Option { + ) -> Option { TransferLedger::from_transaction(logs, native).net_swap(tracked) } @@ -759,63 +694,4 @@ mod tests { ); assert_eq!(transfer_ledger.received_by_address(addr(200), token), U256::ZERO); } - - #[test] - fn test_group_round_trips() { - // The group sends 1000 of token A and gets 300 back: net sent 700, no net receipt. - let group = HashSet::from([addr(99)]); - let logs = vec![ - make_transfer_log(addr(10), addr(99), addr(50), U256::from(1000)), - make_transfer_log(addr(10), addr(50), addr(99), U256::from(300)), - make_transfer_log(addr(11), addr(50), addr(99), U256::from(200)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - assert_eq!( - transfer_ledger.group_net_sent(&group), - HashMap::from([(addr(10), U256::from(700))]) - ); - assert_eq!( - transfer_ledger.group_net_received(&group), - HashMap::from([(addr(11), U256::from(200))]) - ); - } - - #[test] - fn test_sink_payments() { - // The trader pays token_a to the pool (a sender, so never a sink) and, split by the - // token contract, to a wallet that only accumulates. On the token_b side the pool is - // the source of the fee wallet's leg, while the router only forwards what it received. - let trader = addr(1); - let pool = addr(50); - let router = addr(2); - let in_fee_wallet = addr(60); - let out_fee_wallet = addr(61); - let token_a = addr(10); - let token_b = addr(11); - - let logs = vec![ - make_transfer_log(token_a, trader, pool, U256::from(950)), - make_transfer_log(token_a, trader, in_fee_wallet, U256::from(30)), - make_transfer_log(token_a, trader, in_fee_wallet, U256::from(20)), - make_transfer_log(token_b, pool, trader, U256::from(1900)), - make_transfer_log(token_b, pool, out_fee_wallet, U256::from(100)), - make_transfer_log(token_b, pool, router, U256::from(500)), - make_transfer_log(token_b, router, out_fee_wallet, U256::from(500)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - assert_eq!(transfer_ledger.sink_payments(token_a), vec![(in_fee_wallet, U256::from(50))]); - assert_eq!(transfer_ledger.sink_payments(token_b), vec![(out_fee_wallet, U256::from(100))]); - } - - #[test] - fn test_sink_receipts_when_recipient_also_sent() { - // The pool receives and sends (a conversion), the recipient only receives (a sink). - let logs = vec![ - make_transfer_log(addr(10), addr(1), addr(50), U256::from(1000)), - make_transfer_log(addr(11), addr(50), addr(7), U256::from(2000)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - assert_eq!(transfer_ledger.sink_receipts(), vec![(addr(7), addr(11), U256::from(2000))]); - } } diff --git a/tools/hindsight/src/decoder/venue_attribution.rs b/tools/hindsight/src/decoder/venue_attribution.rs deleted file mode 100644 index 0c85af31e..000000000 --- a/tools/hindsight/src/decoder/venue_attribution.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Order-flow venue attribution. -//! -//! A trade's venue is normally the contract the trader entered through (`tx.to`). Some venues own -//! the order flow without being that contract, and are recognized here from the decoded flow — -//! overriding the entry-point label. Every fingerprint is registry-driven; nothing here knows -//! about a specific venue or provider. -//! -//! Four fingerprints, tried in order: -//! - **owning trader** — the swap's net flow was read from a known venue address -//! (`[venue_owners]`). -//! - **`CoW` appData tag** — the settled order committed a frontend tag (`appCode`) whose appData -//! hash maps to a venue (`[venue_appdata]`). The hash is extracted by the caller (it is -//! `CoW`-specific; see `crate::decoder::intents::cow::order_app_data`), so this module stays -//! generic. -//! - **fee wallet** — a known venue fee wallet took a cut of either swap token (`[venue_fees]`); -//! the fee is backed out, on whichever side it came from, so the settled amounts are what -//! actually reached the pools. Checked only inside an already-matched solver trade, so a bare -//! dust transfer to a fee wallet is not mistaken for flow. -//! - **provider integrator tag** — a provider's event carried an integrator string mapped to a -//! venue (`[venue_integrators]`). The tag is extracted by the caller (it is provider-specific; -//! see `crate::decoder::solvers::integrator`), so this module stays generic. - -use std::collections::HashSet; - -use alloy::primitives::{Address, B256, U256}; - -use crate::decoder::{decode::TraderFlow, registry::Registry, transfer_ledger::TransferLedger}; - -/// The order-flow venue for a decoded flow, when a fingerprint matches. On a fee-wallet match the -/// venue's fee is backed out of `flow` — added back to the output, or netted out of the input, -/// depending on which side it was taken from — unless a venue decoder already accounted a fee. -pub(crate) fn attribute( - registry: &Registry, - flow: &mut TraderFlow, - ledger: &TransferLedger, - integrator: Option<&str>, - app_data: Option, -) -> Option { - if let Some(venue) = registry.venue_for_owner(flow.tracked) { - return Some(venue.to_string()); - } - if let Some(venue) = app_data.and_then(|hash| registry.venue_for_appdata(hash)) { - return Some(venue.to_string()); - } - if let Some((venue, fee)) = fee_venue(registry, ledger, flow.swap.token_in, flow.swap.token_out) - { - match fee { - VenueFee::Input(amount) => flow.net_input_fee(amount), - VenueFee::Output(amount) => flow.gross_output_fee(amount), - } - return Some(venue); - } - integrator - .and_then(|tag| registry.venue_for_integrator(tag)) - .map(str::to_string) -} - -/// Which side of the swap a venue took its fee from, with the amount. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VenueFee { - /// Skimmed off the input before the swap, so the settled input is smaller than the user spent. - Input(U256), - /// Taken out of the output after the swap, so the settled output is larger than the user kept. - Output(U256), -} - -/// The venue whose fee wallet took a cut of this trade, and which side it came from. `None` when no -/// venue fee wallet received a non-zero amount of either swap token. -/// -/// Both sides are checked because venues split on this: Phantom and Robinhood take the buy token, -/// while Coinbase's Base App skims the sell token before routing. The output side is tried first — -/// a wallet that received both tokens is being paid its cut in the token the user bought. -fn fee_venue( - registry: &Registry, - ledger: &TransferLedger, - token_in: Address, - token_out: Address, -) -> Option<(String, VenueFee)> { - for (wallet, venue) in registry.venue_fees() { - let received = ledger.received_by(&HashSet::from([*wallet])); - let non_zero = |token: &Address| { - received - .get(token) - .copied() - .filter(|amount| !amount.is_zero()) - }; - if let Some(fee) = non_zero(&token_out) { - return Some((venue.clone(), VenueFee::Output(fee))); - } - if let Some(fee) = non_zero(&token_in) { - return Some((venue.clone(), VenueFee::Input(fee))); - } - } - None -} - -#[cfg(test)] -mod tests { - use alloy::primitives::{address, b256}; - use tycho_simulation::tycho_common::models::Chain; - - use super::*; - use crate::decoder::test_utils::{addr, make_transfer_log, swap}; - - #[test] - fn test_attributes_owner_to_venue() { - // A CoW-settled kpk trade nets to the Safe that owns the order; the venue is that Safe. - let registry = Registry::ethereum(); - let kpk_safe = address!("0x4f2083f5fbede34c2714affb3105539775f7fe64"); - let ledger = TransferLedger::from_transaction(&[], &[]); - let mut flow = TraderFlow::without_fees(kpk_safe, swap(addr(10), 1, addr(11), 2)); - assert_eq!(attribute(®istry, &mut flow, &ledger, None, None).as_deref(), Some("kpk")); - } - - #[test] - fn test_unknown_owner_is_not_a_venue() { - let registry = Registry::ethereum(); - let ledger = TransferLedger::from_transaction(&[], &[]); - let mut flow = TraderFlow::without_fees(addr(9), swap(addr(10), 1, addr(11), 2)); - assert_eq!(attribute(®istry, &mut flow, &ledger, None, None), None); - } - - #[test] - fn test_appdata_tag_attributes_venue() { - // A CoW order carrying DefiLlama's appData hash is attributed to LlamaSwap; an unregistered - // hash is not. - let registry = Registry::ethereum(); - let ledger = TransferLedger::from_transaction(&[], &[]); - let defillama = b256!("0xf249b3db926aa5b5a1b18f3fec86b9cc99b9a8a99ad7e8034242d2838ae97422"); - let mut flow = TraderFlow::without_fees(addr(1), swap(addr(10), 1, addr(11), 2)); - assert_eq!( - attribute(®istry, &mut flow, &ledger, None, Some(defillama)).as_deref(), - Some("llamaswap") - ); - assert_eq!(attribute(®istry, &mut flow, &ledger, None, Some(B256::ZERO)), None); - } - - #[test] - fn test_fee_wallet_attributes_and_grosses_fee_back() { - // A 0x-routed Phantom swap: the buy-token fee reaches Phantom's wallet. It must be added - // back so the settled output is gross (else every Phantom swap under-reports by 85 bps). - let registry = Registry::ethereum(); - let phantom = address!("0x2cffed5d56eb6a17662756ca0fdf350e732c9818"); - let user = addr(1); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - let logs = vec![ - make_transfer_log(token_in, user, pool, U256::from(1000)), - make_transfer_log(token_out, pool, user, U256::from(9915)), - make_transfer_log(token_out, pool, phantom, U256::from(85)), - ]; - let ledger = TransferLedger::from_transaction(&logs, &[]); - let mut flow = TraderFlow::without_fees(user, swap(token_in, 1000, token_out, 9915)); - - assert_eq!( - attribute(®istry, &mut flow, &ledger, None, None).as_deref(), - Some("phantom") - ); - assert_eq!(flow.venue_fee_out, Some(U256::from(85))); - assert_eq!(flow.swap.amount_out, U256::from(10000)); - } - - #[test] - fn test_integrator_tag_attributes_venue() { - // A provider integrator tag maps to its venue, case-insensitively; an unknown tag does - // not. - let registry = Registry::ethereum(); - let ledger = TransferLedger::from_transaction(&[], &[]); - let mut flow = TraderFlow::without_fees(addr(1), swap(addr(10), 1, addr(11), 2)); - assert_eq!( - attribute(®istry, &mut flow, &ledger, Some("Infinex"), None).as_deref(), - Some("infinex") - ); - assert_eq!(attribute(®istry, &mut flow, &ledger, Some("somedapp"), None), None); - } - - #[test] - fn test_fee_wallet_input_side_fee_nets_the_input_down() { - // A LiFi-routed Coinbase Base App swap: the 0.95% cut is skimmed off the sell token before - // routing, so only the remainder reached the pools. Leaving it in makes the settled trade - // look bigger than it was and Fynd, re-solved on that inflated size, appear to win. - let registry = Registry::builtin(Chain::Bsc).unwrap(); - let coinbase = address!("0x5aafc1f252d544f744d17a4e734afd6efc47ede4"); - let user = addr(1); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - let logs = vec![ - make_transfer_log(token_in, user, coinbase, U256::from(95)), - make_transfer_log(token_in, user, pool, U256::from(9905)), - make_transfer_log(token_out, pool, user, U256::from(2000)), - ]; - let ledger = TransferLedger::from_transaction(&logs, &[]); - let mut flow = TraderFlow::without_fees(user, swap(token_in, 10000, token_out, 2000)); - - assert_eq!( - attribute(®istry, &mut flow, &ledger, Some("base-app"), None).as_deref(), - Some("coinbase") - ); - assert_eq!(flow.venue_fee_in, Some(U256::from(95))); - assert_eq!(flow.swap.amount_in, U256::from(9905)); - // The output side is untouched: this venue took nothing out of the buy token. - assert_eq!(flow.venue_fee_out, None); - assert_eq!(flow.swap.amount_out, U256::from(2000)); - } - - #[test] - fn test_fee_wallet_taking_both_tokens_is_read_as_an_output_fee() { - // A wallet that received both swap tokens is being paid its cut in the token the user - // bought; the sell-token leg is the swap's own routing, not a second fee. - let registry = Registry::ethereum(); - let phantom = address!("0x2cffed5d56eb6a17662756ca0fdf350e732c9818"); - let user = addr(1); - let token_in = addr(10); - let token_out = addr(11); - let logs = vec![ - make_transfer_log(token_in, user, phantom, U256::from(7)), - make_transfer_log(token_out, addr(50), phantom, U256::from(85)), - ]; - let ledger = TransferLedger::from_transaction(&logs, &[]); - let mut flow = TraderFlow::without_fees(user, swap(token_in, 1000, token_out, 9915)); - - assert_eq!( - attribute(®istry, &mut flow, &ledger, None, None).as_deref(), - Some("phantom") - ); - assert_eq!(flow.venue_fee_out, Some(U256::from(85))); - assert_eq!(flow.swap.amount_out, U256::from(10000)); - assert_eq!(flow.venue_fee_in, None); - assert_eq!(flow.swap.amount_in, U256::from(1000)); - } - - #[test] - fn test_no_fee_transfer_is_not_a_venue() { - // Dust to the fee wallet in a token other than the output is not this trade's fee. - let registry = Registry::ethereum(); - let user = addr(1); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - let logs = vec![ - make_transfer_log(token_in, user, pool, U256::from(1000)), - make_transfer_log(token_out, pool, user, U256::from(2000)), - ]; - let ledger = TransferLedger::from_transaction(&logs, &[]); - let mut flow = TraderFlow::without_fees(user, swap(token_in, 1000, token_out, 2000)); - assert_eq!(attribute(®istry, &mut flow, &ledger, None, None), None); - } -} diff --git a/tools/hindsight/src/decoder/venues/coinbase.rs b/tools/hindsight/src/decoder/venues/coinbase.rs deleted file mode 100644 index 396ba5b96..000000000 --- a/tools/hindsight/src/decoder/venues/coinbase.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Coinbase Wallet decoding. -//! -//! Coinbase Wallet's in-app swaps are 0x-powered ("aggregation is powered by 0x", -//! docs.cdp.coinbase.com): the app enters through its own proxy contracts and takes a fee in the -//! output token, sent to its fee wallet. Nets the sender's flow and backs that fee out through the -//! shared `venue_flow` — no venue-specific corrections. - -use alloy::providers::Provider; -use async_trait::async_trait; - -use crate::decoder::{ - decode::{DecodeContext, TradeDecoder, TraderFlow}, - netting_decoders::venue_flow, -}; - -/// Coinbase Wallet's netting decoder. -pub(crate) struct CoinbaseNetting; - -#[async_trait] -impl TradeDecoder

for CoinbaseNetting { - fn name(&self) -> &'static str { - "coinbase-netting" - } - - /// Net the sender's flow and back the output-token fee out. - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - let addresses = ctx.venue?; - venue_flow( - ctx.transfer_ledger, - ctx.receipt.from, - ctx.entry_point, - &addresses.fee_collectors, - ) - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use alloy::{ - primitives::{Address, U256}, - providers::RootProvider, - rpc::client::RpcClient, - transports::mock::Asserter, - }; - - use super::*; - use crate::decoder::{ - registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, - transfer_ledger::TransferLedger, - }; - - fn fee_wallet(registry: &Registry) -> Address { - *registry - .venue("coinbase") - .unwrap() - .fee_collectors - .iter() - .next() - .unwrap() - } - - async fn decode( - registry: &Registry, - ledger: &TransferLedger, - sender: Address, - entry_point: Address, - ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); - let receipt = receipt(tx_hash(1), sender, Some(entry_point), vec![]); - let root = frame("CALL", sender, entry_point, 0); - let mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input: &[], - root: &root, - venue: registry.venue("coinbase"), - }; - CoinbaseNetting.decode(&mut ctx).await - } - - #[tokio::test] - async fn test_output_token_fee_backed_out() { - // The fee reaches the collector in the output token, so the shared back-out grosses it into - // amount_out — else the settled output is under-reported and every comparison overcredits - // Fynd. - let registry = Registry::ethereum(); - let collector = fee_wallet(®istry); - let user = addr(1); - let proxy = addr(2); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - - let logs = vec![ - make_transfer_log(token_in, user, pool, U256::from(1000)), - make_transfer_log(token_out, pool, user, U256::from(1990)), - make_transfer_log(token_out, pool, collector, U256::from(10)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - let flow = decode(®istry, &transfer_ledger, user, proxy) - .await - .unwrap(); - assert_eq!(flow.swap, swap(token_in, 1000, token_out, 2000)); - assert_eq!(flow.venue_fee_out, Some(U256::from(10))); - } - - #[tokio::test] - async fn test_fee_free_trade() { - let registry = Registry::ethereum(); - let user = addr(1); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - - let logs = vec![ - make_transfer_log(token_in, user, pool, U256::from(1000)), - make_transfer_log(token_out, pool, user, U256::from(2000)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - let flow = decode(®istry, &transfer_ledger, user, pool) - .await - .unwrap(); - assert_eq!(flow.swap, swap(token_in, 1000, token_out, 2000)); - assert_eq!(flow.venue_fee_out, None); - } -} diff --git a/tools/hindsight/src/decoder/venues/metamask.rs b/tools/hindsight/src/decoder/venues/metamask.rs deleted file mode 100644 index eae4787f5..000000000 --- a/tools/hindsight/src/decoder/venues/metamask.rs +++ /dev/null @@ -1,262 +0,0 @@ -//! `MetaMask` decoding. -//! -//! `MetaMask`'s Swap Router routes through a real solver and takes its fee (~87.5 bps, plus a gas -//! recoup on gasless "smart swaps") to a fee wallet — from the input token before swapping or -//! from the output after. The fee is charged whichever router `MetaMask` plugs in, so it is not -//! value better routing can recover; without backing it out, every comparison credits Fynd with -//! `MetaMask`'s own fee, and on dust trades — where the fee dominates — that fabricates extreme -//! "wins". - -use alloy::{providers::Provider, sol, sol_types::SolCall}; -use async_trait::async_trait; - -use crate::decoder::{ - decode::{DecodeContext, TradeDecoder, TraderFlow}, - netting_decoders::venue_flow, - registry::VenueAddresses, -}; - -sol! { - /// The `MetaMask` Swap Router entry point (selector `0x5f575529`): `aggregatorId` names the - /// solver API that produced the route. - function swap(string aggregatorId, address tokenFrom, uint256 amount, bytes data); -} - -/// `MetaMask`'s netting decoder. -pub(crate) struct MetaMaskNetting; - -#[async_trait] -impl TradeDecoder

for MetaMaskNetting { - fn name(&self) -> &'static str { - "metamask-netting" - } - - /// Net the sender's flow, back the venue fee out of it, and attribute the solver from the - /// router calldata. - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - let addresses = ctx.venue?; - let mut flow = venue_flow( - ctx.transfer_ledger, - ctx.receipt.from, - ctx.entry_point, - &addresses.fee_collectors, - )?; - flow.solver_override = solver_from_calldata(ctx.input, addresses); - Some(flow) - } -} - -/// The solver label declared in the router calldata's `aggregatorId`, normalized to the address -/// book's solver names via the `[venues.metamask.solver_aliases]` section. -/// -/// `MetaMask` states which solver API it routed through (e.g. "oneInchV6FeeDynamic", -/// "uniswapPermit2FeeDynamic"). Trace attribution often cannot resolve these — a token→token -/// route moves no native value and enters through Permit2 — so the calldata declaration is the -/// authoritative source. -fn solver_from_calldata(input: &[u8], metamask: &VenueAddresses) -> Option { - let call = swapCall::abi_decode(input).ok()?; - Some(metamask.normalize_solver(&call.aggregatorId)) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use alloy::{ - primitives::{Address, Bytes, U256}, - providers::RootProvider, - rpc::client::RpcClient, - transports::mock::Asserter, - }; - - use super::*; - use crate::decoder::{ - decode::GasScope, - registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, - transfer_ledger::TransferLedger, - }; - - fn metamask_addresses(registry: &Registry) -> &VenueAddresses { - registry.venue("metamask").unwrap() - } - - fn fee_wallet(registry: &Registry) -> Address { - *metamask_addresses(registry) - .fee_collectors - .iter() - .next() - .unwrap() - } - - fn router(registry: &Registry) -> Address { - *metamask_addresses(registry) - .entry_points - .iter() - .next() - .unwrap() - } - - /// Decode a `MetaMask` transaction through the full `MetaMaskNetting` decoder. - async fn decode( - registry: &Registry, - ledger: &TransferLedger, - sender: Address, - entry_point: Address, - input: &[u8], - ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); - let receipt = receipt(tx_hash(1), sender, Some(entry_point), vec![]); - let root = frame("CALL", sender, entry_point, 0); - let mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input, - root: &root, - venue: registry.venue("metamask"), - }; - MetaMaskNetting.decode(&mut ctx).await - } - - #[test] - fn test_swap_selector_against_deployed_router() { - // The sol! declaration must match the on-chain function (verified against live calldata). - assert_eq!(swapCall::SELECTOR, [0x5f, 0x57, 0x55, 0x29]); - } - - #[test] - fn test_solver_from_calldata_known_ids() { - let registry = Registry::ethereum(); - let metamask = metamask_addresses(®istry); - for (id, want) in [ - ("oneInchV6FeeDynamic", "1inch"), - ("uniswapPermit2FeeDynamic", "uniswap"), - ("okx6", "okx"), - ("someFutureSolver", "someFutureSolver"), - ] { - let call = swapCall { - aggregatorId: id.to_string(), - tokenFrom: addr(10), - amount: U256::from(1000), - data: Bytes::default(), - }; - assert_eq!(solver_from_calldata(&call.abi_encode(), metamask).as_deref(), Some(want)); - } - } - - #[test] - fn test_solver_from_calldata_other_selectors() { - let registry = Registry::ethereum(); - let metamask = metamask_addresses(®istry); - assert_eq!(solver_from_calldata(&[0xde, 0xad, 0xbe, 0xef, 0x00], metamask), None); - assert_eq!(solver_from_calldata(&[], metamask), None); - } - - #[tokio::test] - async fn test_output_side_fee() { - // Live tx 0x142de458… shape: token in, ETH out; the router takes the fee from the native - // output before forwarding the rest to the trader. amount_out is grossed back up. - let registry = Registry::ethereum(); - let collector = fee_wallet(®istry); - let user = addr(1); - let router = router(®istry); - let pool = addr(50); - let token_in = addr(10); - - let logs = vec![make_transfer_log(token_in, user, pool, U256::from(15_000_000))]; - let native = vec![ - (pool, router, U256::from(8_408)), - (router, collector, U256::from(883)), - (router, user, U256::from(7_525)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &native); - - let flow = decode(®istry, &transfer_ledger, user, router, &[]) - .await - .unwrap(); - assert_eq!(flow.tracked, user); - assert_eq!(flow.swap, swap(token_in, 15_000_000, Address::ZERO, 8_408)); - assert_eq!(flow.venue_fee_in, None); - assert_eq!(flow.venue_fee_out, Some(U256::from(883))); - assert_eq!(flow.gas_scope, GasScope::SolverFrame); - } - - #[tokio::test] - async fn test_input_side_fee() { - // ETH in, token out: the router takes the fee from the native input before forwarding - // the rest to the solver. amount_in shrinks to what actually entered the swap. - let registry = Registry::ethereum(); - let collector = fee_wallet(®istry); - let user = addr(1); - let router = router(®istry); - let pool = addr(50); - let token_out = addr(11); - - let native = vec![ - (user, router, U256::from(1_000)), - (router, collector, U256::from(9)), - (router, pool, U256::from(991)), - ]; - let logs = vec![make_transfer_log(token_out, pool, user, U256::from(2_000))]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &native); - - let flow = decode(®istry, &transfer_ledger, user, router, &[]) - .await - .unwrap(); - assert_eq!(flow.swap, swap(Address::ZERO, 991, token_out, 2_000)); - assert_eq!(flow.venue_fee_in, Some(U256::from(9))); - assert_eq!(flow.venue_fee_out, None); - } - - #[tokio::test] - async fn test_solver_declaration() { - // The declared aggregatorId lands on the flow as the solver override, so the orchestrator - // needs no MetaMask-specific attribution branch. - let registry = Registry::ethereum(); - let user = addr(1); - let pool = addr(50); - let logs = vec![ - make_transfer_log(addr(10), user, pool, U256::from(1_000)), - make_transfer_log(addr(11), pool, user, U256::from(2_000)), - ]; - let call = swapCall { - aggregatorId: "oneInchV6FeeDynamic".to_string(), - tokenFrom: addr(10), - amount: U256::from(1_000), - data: Bytes::default(), - }; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - let flow = decode(®istry, &transfer_ledger, user, router(®istry), &call.abi_encode()) - .await - .unwrap(); - assert_eq!(flow.solver_override.as_deref(), Some("1inch")); - } - - #[tokio::test] - async fn test_fee_free_trade() { - let registry = Registry::ethereum(); - let user = addr(1); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - - let logs = vec![ - make_transfer_log(token_in, user, pool, U256::from(1_000)), - make_transfer_log(token_out, pool, user, U256::from(2_000)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - let flow = decode(®istry, &transfer_ledger, user, router(®istry), &[]) - .await - .unwrap(); - assert_eq!(flow.swap, swap(token_in, 1_000, token_out, 2_000)); - assert_eq!(flow.venue_fee_in, None); - assert_eq!(flow.venue_fee_out, None); - } -} diff --git a/tools/hindsight/src/decoder/venues/mod.rs b/tools/hindsight/src/decoder/venues/mod.rs deleted file mode 100644 index eda8c24e8..000000000 --- a/tools/hindsight/src/decoder/venues/mod.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Venue-specific decoders: the platforms users enter through (Relay, `MetaMask`). -//! -//! A venue owns the order flow — it picks a solver and may take a fee. One module here is one -//! venue, holding every decoder for it. A venue lists its decoders in `decoders_for`, tried in -//! order: today each is a netting decoder (net the sender, back the fee out, add venue-specific -//! corrections), and a venue that is better read from its calldata would add a calldata decoder -//! ahead of or behind netting. -//! -//! Its address facts — entry points, fee collectors, solver aliases — are pure data in the -//! address book's `[venues.]` section, handed to the decoder through the context. -//! -//! # What happens when a venue is missing -//! -//! Missing venue knowledge does not stop decoding — it degrades it, silently: -//! -//! - **Venue not in the address book at all**: its transactions only match when a known solver -//! emitted a log inside them, and those decode via intent decoding, which excludes the sender — -//! so most of the venue's trades are missed or declined. They surface as coverage gaps in -//! `verify`, not as wrong records. -//! - **Venue registered but a fee collector is missing**: trades decode, but wrongly — the fee is -//! not backed out, so the amounts include the venue's fee, and every comparison credits Fynd with -//! money better routing cannot recover. -//! -//! The second failure mode is why fee collectors are verified against on-chain samples (see the -//! address book's comments) before a venue is added. - -pub(crate) mod coinbase; -pub(crate) mod metamask; -pub(crate) mod rabby; -pub(crate) mod rainbow; -pub(crate) mod relay; - -use alloy::providers::{Provider, RootProvider}; - -use crate::decoder::decode::TradeDecoder; - -/// The decoders tried for a venue, in order (first hit wins). This is the one place a venue is -/// registered — adding a venue is a `mod` declaration plus one arm here. A name that resolves to -/// no decoders is rejected by the registry at load time (see `has_decoder`). -pub(crate) fn decoders_for(name: &str) -> Vec>> { - match name { - "relay" => vec![Box::new(relay::RelayCalldata), Box::new(relay::RelayNetting)], - "metamask" => vec![Box::new(metamask::MetaMaskNetting)], - "rabby" => vec![Box::new(rabby::RabbyNetting)], - "coinbase" => vec![Box::new(coinbase::CoinbaseNetting)], - "rainbow" => vec![Box::new(rainbow::RainbowCalldata)], - _ => vec![], - } -} - -/// Whether a venue name resolves to a decoder — derived from `decoders_for` so the two cannot -/// drift. The registry uses this at load time to reject an address-book venue with no decoder. -/// The provider type is irrelevant; only whether a decoder exists matters. -pub(crate) fn has_decoder(name: &str) -> bool { - !decoders_for::(name).is_empty() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_has_decoder_follows_decoders_for() { - // A registered venue resolves; an unknown name does not. Adding a venue needs no change - // here — `has_decoder` derives from the one `decoders_for` registration. - assert!(has_decoder("relay")); - assert!(!has_decoder("nope")); - } -} diff --git a/tools/hindsight/src/decoder/venues/rabby.rs b/tools/hindsight/src/decoder/venues/rabby.rs deleted file mode 100644 index 27a1bd396..000000000 --- a/tools/hindsight/src/decoder/venues/rabby.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Rabby decoding. -//! -//! Rabby is a consumer wallet with its own meta-aggregator: it picks among many solvers and takes -//! a flat 0.25% of the output token as its fee. Only its Uniswap-routed swaps enter through -//! Rabby's own `SwapProxy` contract; the rest go straight to the chosen solver's router, where -//! `tx.to` is that solver and the sole Rabby fingerprint is the fee transfer. Matching keys on the -//! entry point, so only the `SwapProxy` swaps are recognized as Rabby here — the shared-router -//! swaps decode as the solver's own trades with the 0.25% fee still inside the amounts. -//! -//! On a swap whose output is native ETH, Rabby unwraps the proceeds to the trader but keeps its -//! cut in WETH beforehand, so the fee reaches the collector denominated in the wrapped token -//! while the trade's output token is native ETH. The shared fee back-out matches the exact output -//! token and would miss that, so the wrapped-native fee is recognized here and grossed back into -//! the ETH output. - -use alloy::{primitives::Address, providers::Provider}; -use async_trait::async_trait; - -use crate::decoder::{ - decode::{DecodeContext, TradeDecoder, TraderFlow}, - netting_decoders::venue_flow, -}; - -/// Rabby's netting decoder. -pub(crate) struct RabbyNetting; - -#[async_trait] -impl TradeDecoder

for RabbyNetting { - fn name(&self) -> &'static str { - "rabby-netting" - } - - /// Net the sender's flow and back the 0.25% fee out. A fee in the output token is handled by - /// the shared `venue_flow`; a WETH fee on an ETH-output swap is grossed back in here. - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - let addresses = ctx.venue?; - let mut flow = venue_flow( - ctx.transfer_ledger, - ctx.receipt.from, - ctx.entry_point, - &addresses.fee_collectors, - )?; - - if flow.swap.token_out == Address::ZERO { - let wrapped_fee = ctx - .transfer_ledger - .received_by(&addresses.fee_collectors) - .get(&ctx.registry.wrapped_native()) - .copied() - .filter(|fee| !fee.is_zero()); - if let Some(fee) = wrapped_fee { - flow.gross_output_fee(fee); - } - } - Some(flow) - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use alloy::{ - primitives::U256, providers::RootProvider, rpc::client::RpcClient, - transports::mock::Asserter, - }; - - use super::*; - use crate::decoder::{ - decode::GasScope, - registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, - transfer_ledger::TransferLedger, - }; - - fn fee_wallet(registry: &Registry) -> Address { - *registry - .venue("rabby") - .unwrap() - .fee_collectors - .iter() - .next() - .unwrap() - } - - /// Decode a Rabby transaction through the full `RabbyNetting` decoder. - async fn decode( - registry: &Registry, - ledger: &TransferLedger, - sender: Address, - entry_point: Address, - ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); - let receipt = receipt(tx_hash(1), sender, Some(entry_point), vec![]); - let root = frame("CALL", sender, entry_point, 0); - let mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input: &[], - root: &root, - venue: registry.venue("rabby"), - }; - RabbyNetting.decode(&mut ctx).await - } - - #[tokio::test] - async fn test_eth_output_wraps_fee_back_in() { - // Live tx 0x96c81d9b… shape: USDC in, ETH out through the SwapProxy. Rabby takes its - // 0.25% cut in WETH before unwrapping the rest to the trader, so the fee reaches the - // collector as WETH while the output token is native ETH. It must be grossed back into - // amount_out, else every ETH-output Rabby swap under-reports the settled output by 25 bps. - let registry = Registry::ethereum(); - let collector = fee_wallet(®istry); - let user = addr(1); - let router = addr(2); - let pool = addr(50); - let usdc = addr(10); - let weth = registry.wrapped_native(); - - let logs = vec![ - make_transfer_log(usdc, user, pool, U256::from(4000)), - make_transfer_log(weth, pool, router, U256::from(8000)), - make_transfer_log(weth, router, collector, U256::from(20)), - ]; - let native = vec![(router, user, U256::from(7980))]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &native); - - let flow = decode(®istry, &transfer_ledger, user, router) - .await - .unwrap(); - assert_eq!(flow.tracked, user); - assert_eq!(flow.swap, swap(usdc, 4000, Address::ZERO, 8000)); - assert_eq!(flow.venue_fee_in, None); - assert_eq!(flow.venue_fee_out, Some(U256::from(20))); - assert_eq!(flow.gas_scope, GasScope::SolverFrame); - } - - #[tokio::test] - async fn test_token_output_fee() { - // Token-to-token swap: the fee reaches the collector in the output token itself, so the - // shared back-out grosses it in and the Rabby wrapped-native branch stays out of the way. - let registry = Registry::ethereum(); - let collector = fee_wallet(®istry); - let user = addr(1); - let router = addr(2); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - - let logs = vec![ - make_transfer_log(token_in, user, pool, U256::from(1000)), - make_transfer_log(token_out, pool, user, U256::from(1995)), - make_transfer_log(token_out, pool, collector, U256::from(5)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - let flow = decode(®istry, &transfer_ledger, user, router) - .await - .unwrap(); - assert_eq!(flow.swap, swap(token_in, 1000, token_out, 2000)); - assert_eq!(flow.venue_fee_out, Some(U256::from(5))); - } - - #[tokio::test] - async fn test_fee_free_trade() { - let registry = Registry::ethereum(); - let user = addr(1); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - - let logs = vec![ - make_transfer_log(token_in, user, pool, U256::from(1000)), - make_transfer_log(token_out, pool, user, U256::from(2000)), - ]; - let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); - - let flow = decode(®istry, &transfer_ledger, user, pool) - .await - .unwrap(); - assert_eq!(flow.swap, swap(token_in, 1000, token_out, 2000)); - assert_eq!(flow.venue_fee_in, None); - assert_eq!(flow.venue_fee_out, None); - } -} diff --git a/tools/hindsight/src/decoder/venues/rainbow.rs b/tools/hindsight/src/decoder/venues/rainbow.rs deleted file mode 100644 index 0f9d55c20..000000000 --- a/tools/hindsight/src/decoder/venues/rainbow.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Rainbow decoding. -//! -//! Rainbow is a consumer wallet with its own router (`0x0000…10e2`, the same address on every -//! chain it supports). It wraps 0x and takes its fee on the input side, passed explicitly as the -//! call's `feeAmount` argument and kept by the router — there is no fee transfer to observe, so the -//! fee is read from the calldata. -//! -//! Only the ETH→token entry (`fillQuoteEthToToken`) is decoded: its `feeAmount` is an absolute -//! amount of the input ETH (verified on-chain, tx 0xe09cf895…). The token→ETH and token→token -//! entries encode their cut as a basis-point rate instead (see `rainbow-me/swaps`), so they are -//! declined until that is verified — a declined trade is a coverage gap, never a mis-priced record. - -use std::collections::HashSet; - -use alloy::{primitives::U256, providers::Provider, sol, sol_types::SolCall}; -use async_trait::async_trait; - -use crate::decoder::{ - decode::{DecodeContext, TradeDecoder, TraderFlow}, - netting_decoders::venue_flow, -}; - -sol! { - /// Rainbow's ETH→token entry; `feeAmount` is the input-side fee the router keeps. - function fillQuoteEthToToken(address buyToken, address to, bytes data, uint256 feeAmount); -} - -/// Rainbow's calldata decoder. -pub(crate) struct RainbowCalldata; - -#[async_trait] -impl TradeDecoder

for RainbowCalldata { - fn name(&self) -> &'static str { - "rainbow-calldata" - } - - /// Net the sender's flow, then subtract the input-side fee read from the calldata so the - /// amount that entered the swap is comparable to a re-solve. Declines any non-ETH→token call. - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - let fee = eth_to_token_fee(ctx.input)?; - // The router keeps no fee transfer, so there is nothing for `venue_flow` to back out; it - // just nets the sender. The input-side fee is applied here. - let mut flow = - venue_flow(ctx.transfer_ledger, ctx.receipt.from, ctx.entry_point, &HashSet::new())?; - flow.venue_fee_in = Some(fee); - flow.swap.amount_in = flow.swap.amount_in.saturating_sub(fee); - Some(flow) - } -} - -/// The input-side fee of a `fillQuoteEthToToken` call, or `None` for any other selector or a -/// malformed input. -fn eth_to_token_fee(input: &[u8]) -> Option { - fillQuoteEthToTokenCall::abi_decode(input) - .ok() - .map(|call| call.feeAmount) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use alloy::{ - primitives::{Address, U256}, - providers::RootProvider, - rpc::client::RpcClient, - transports::mock::Asserter, - }; - - use super::*; - use crate::decoder::{ - registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, - transfer_ledger::TransferLedger, - }; - - /// `fillQuoteEthToToken` calldata carrying `fee` as its `feeAmount` argument. - fn eth_to_token_calldata(fee: u64) -> Vec { - fillQuoteEthToTokenCall { - buyToken: Address::ZERO, - to: Address::ZERO, - data: alloy::primitives::Bytes::default(), - feeAmount: U256::from(fee), - } - .abi_encode() - } - - async fn decode( - input: &[u8], - ledger: &TransferLedger, - entry_point: Address, - ) -> Option { - let registry = Registry::ethereum(); - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); - let user = addr(1); - let receipt = receipt(tx_hash(1), user, Some(entry_point), vec![]); - let root = frame("CALL", user, entry_point, 0); - let mut ctx = DecodeContext { - provider: &provider, - registry: ®istry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input, - root: &root, - venue: registry.venue("rainbow"), - }; - RainbowCalldata.decode(&mut ctx).await - } - - #[tokio::test] - async fn test_eth_to_token_subtracts_input_fee() { - // ETH in, token out through the Rainbow router: the fee is part of the ETH the user sent - // but never entered the swap, so amount_in must drop by it (else Fynd is handed the fee). - let user = addr(1); - let router = addr(2); - let pool = addr(50); - let token_out = addr(11); - let native = vec![(user, router, U256::from(18_500))]; - let logs = vec![make_transfer_log(token_out, pool, user, U256::from(34_000))]; - let ledger = TransferLedger::from_transaction(&logs, &native); - - let flow = decode(ð_to_token_calldata(157), &ledger, router) - .await - .unwrap(); - assert_eq!(flow.swap, swap(Address::ZERO, 18_500 - 157, token_out, 34_000)); - assert_eq!(flow.venue_fee_in, Some(U256::from(157))); - assert_eq!(flow.venue_fee_out, None); - } - - #[tokio::test] - async fn test_other_selector_declined() { - // A non-ETH→token call (here an empty/unknown selector) is declined rather than decoded - // without its fee. - let user = addr(1); - let router = addr(2); - let pool = addr(50); - let token_out = addr(11); - let native = vec![(user, router, U256::from(18_500))]; - let logs = vec![make_transfer_log(token_out, pool, user, U256::from(34_000))]; - let ledger = TransferLedger::from_transaction(&logs, &native); - - assert!(decode(&[0xde, 0xad, 0xbe, 0xef], &ledger, router) - .await - .is_none()); - } -} diff --git a/tools/hindsight/src/decoder/venues/relay.rs b/tools/hindsight/src/decoder/venues/relay.rs deleted file mode 100644 index dbd870f07..000000000 --- a/tools/hindsight/src/decoder/venues/relay.rs +++ /dev/null @@ -1,727 +0,0 @@ -//! Relay decoding. -//! -//! Relay differs from direct solver swaps in two ways: its router sends a venue fee to a collector -//! address on either side of the swap, and its solvers submit rebalancing fills whose transaction -//! sender has no net flow. -//! -//! Two decoders, tried in order (`venues::decoders_for`): [`RelayCalldata`] reads the trader's -//! terms straight from the settling solver's own calldata, and [`RelayNetting`] nets the ledger -//! for the solvers `RelayCalldata` cannot parse (0x Settler) or transactions with no solver frame -//! at all. See `.claude/plans/calldata-first-decoding.md` for the empirics behind the ordering. - -use std::collections::HashSet; - -use alloy::{ - primitives::{Address, U256}, - providers::Provider, -}; -use async_trait::async_trait; - -use crate::decoder::{ - decode::{DecodeContext, GasScope, TradeDecoder, TraderFlow}, - netting_decoders::venue_flow, - solvers, trace, - transfer_ledger::{NetSwap, TransferLedger, RESIDUE_GROSS_RATIO}, -}; - -/// Relay's calldata-primary decoder. -/// -/// Reads `token_in`/`token_out`/`amount_in` straight from the settling solver frame's `SwapIntent` -/// (already the post-fee, on-chain-enforced terms — Relay pays its input-side fee to the -/// collector *before* forwarding into the solver call) and recovers the settled `amount_out` as -/// the gross amount of `intent.token_out` received by the output recipient the same calldata -/// declares — the one field calldata can never carry. Declines (falling through to -/// [`RelayNetting`]) when no solver frame or intent is found, the recipient never received the -/// token, or either guard below fails. -/// -/// Two guards protect against the recipient-receipt query mis-attributing a multi-order -/// transaction's output (see the design doc's risk section — not observed in the sampled traffic, -/// but cheap to check): the recovered output must clear the intent's on-chain floor (a successful -/// trade cleared it by construction, so a violation means the wrong legs were picked up), and, -/// when the calldata also declares a quote, it must sit within `plausible_quote`'s band of the -/// recovered output. -pub(crate) struct RelayCalldata; - -#[async_trait] -impl TradeDecoder

for RelayCalldata { - fn name(&self) -> &'static str { - "relay-calldata" - } - - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - let addresses = ctx.venue?; - let solver_frame = trace::find_solver_frame(ctx.root, ctx.registry)?; - let solver = ctx.registry.label(solver_frame.to?); - let intent = solvers::swap_intent(&solver, &solver_frame.input, None)?; - let recipient = solvers::output_recipient(&solver, &solver_frame.input)?; - - let amount_out = ctx - .transfer_ledger - .received_by_address(recipient, intent.token_out); - if amount_out.is_zero() || amount_out < intent.min_amount_out { - return None; - } - if let Some(quoted) = intent.declared_quote() { - if !solvers::plausible_quote(quoted, amount_out) { - return None; - } - } - - // Both fees are already on the right basis (§1 of the design doc): the intent's - // `amount_in` is post-input-fee and the recipient's receipt is pre-output-fee, so neither - // amount above needs adjusting — the fee is recorded for transparency only. - let fees = ctx - .transfer_ledger - .received_by(&addresses.fee_collectors); - let venue_fee_in = fees - .get(&intent.token_in) - .copied() - .filter(|fee| !fee.is_zero()); - let venue_fee_out = fees - .get(&intent.token_out) - .copied() - .filter(|fee| !fee.is_zero()); - - // A trader-sent Relay transaction charges the solver frame's gas (see `GasScope`); a - // solver-initiated rebalance charges nothing. Ledger-derived rather than assumed: the - // sender net-sending the input token is what "trader-funded" means here. - let sender = ctx.receipt.from; - let net_sent = ctx - .transfer_ledger - .group_net_sent(&HashSet::from([sender])); - let gas_scope = if net_sent.contains_key(&intent.token_in) { - GasScope::SolverFrame - } else { - GasScope::NotCharged - }; - - Some(TraderFlow { - tracked: sender, - swap: NetSwap { - token_in: intent.token_in, - amount_in: intent.amount_in, - token_out: intent.token_out, - amount_out, - }, - venue_fee_in, - venue_fee_out, - solver_override: None, - gas_scope, - }) - } -} - -/// Relay's netting decoder. -pub(crate) struct RelayNetting; - -#[async_trait] -impl TradeDecoder

for RelayNetting { - fn name(&self) -> &'static str { - "relay-netting" - } - - /// The common case is a user swap: net the sender's flow, then back the venue fee out of it. - /// When the sender has no net flow the transaction is a solver-initiated rebalancing fill, - /// decoded by anchoring on the fee collector instead (Relay funds the swap from it); the - /// collector is the funding source there, not a fee recipient, so no fee is backed out. - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { - let addresses = ctx.venue?; - if let Some(flow) = venue_flow( - ctx.transfer_ledger, - ctx.receipt.from, - ctx.entry_point, - &addresses.fee_collectors, - ) { - return Some(flow); - } - decode_rebalance( - ctx.transfer_ledger, - &addresses.fee_collectors, - &addresses.entry_points, - ctx.registry.wrapped_native(), - ) - .map(|swap| TraderFlow::without_fees(ctx.receipt.from, swap)) - } -} - -/// Decode a Relay solver-initiated rebalancing fill, where `tx.from` is a rotating solver EOA with -/// no net flow (so sender netting finds nothing) and the swap moves Relay's own liquidity. -/// -/// Anchors on the fee collector, which always funds the input: `token_in` is the single token it -/// net-sends. The output is one of two shapes — the asset received by the single external recipient -/// that only receives and never sends (a cross-chain order fill; see -/// `TransferLedger::sink_receipts`), or, when there is no such recipient, the token that comes back -/// to the collector (an internal inventory rebalance). -/// -/// The external recipient is checked first because the collector also receives Relay's fee on a -/// user fill, and a fee is not a rebalance output. Reading it as one values the trade at the fee -/// instead of the payout: on tx `0x65286b53…` the user received 0.0255 ETH for 50.148 `USDe` while -/// the collector took 0.000536 ETH, and anchoring on the collector reported a 468,826 bps win -/// against a trade that actually beat Fynd by 45 bps. -/// -/// A candidate output under `RESIDUE_GROSS_RATIO` of its token's flow through the transaction is -/// a fee leg rather than the fill, and is skipped. -/// -/// Declines (returns `None`) when the shape is ambiguous: not exactly one input token, a -/// same-token "swap", more than one token back to the collector, or more than one external -/// recipient or output (a batched multi-order fill, like netting's multi-leg decline). -fn decode_rebalance( - transfer_ledger: &TransferLedger, - fee_collectors: &HashSet

, - relay_entry_points: &HashSet
, - wrapped_native: Address, -) -> Option { - let net_in: Vec<(Address, U256)> = transfer_ledger - .group_net_sent(fee_collectors) - .into_iter() - .collect(); - if net_in.len() != 1 { - return None; - } - let (token_in, amount_in) = net_in[0]; - - // C1 external fill: the single pure-sink recipient, excluding infrastructure (routers, - // collector, the wrapped-native token, the zero address) and the input token. - let mut outputs: Vec<(Address, U256)> = Vec::new(); - for (recipient, token, amount) in transfer_ledger.sink_receipts() { - if relay_entry_points.contains(&recipient) || - fee_collectors.contains(&recipient) || - recipient == Address::ZERO || - recipient == wrapped_native - { - continue; - } - // A payout, not a swap: the collector's token reached an external recipient unconverted - // (a cross-chain order settled from same-token inventory). There is no conversion to - // re-solve — pairing the leftover (e.g. a gas top-up) as "the output" fabricated - // seven-figure-bps wins. A genuine fill routes token_in into a pool, which sends - // something back and is therefore never a pure sink. - if token == token_in { - return None; - } - // A sliver of the token's flow is a fee, not the fill. On tx `0x3dc3f83e…` the executor - // paid 0.1% of the bought WETH to one address and 99.9% to the payee, who had also - // forwarded the input and so was not a pure sink — leaving the fee recipient as the only - // candidate. Read as the output it reported a 9,990,000 bps win on a trade Fynd matched. - if amount.saturating_mul(U256::from(RESIDUE_GROSS_RATIO)) < - transfer_ledger.token_gross(token) - { - continue; - } - outputs.push((token, amount)); - } - if !outputs.is_empty() { - if outputs.len() != 1 { - return None; - } - let (token_out, amount_out) = outputs[0]; - return Some(NetSwap { token_in, amount_in, token_out, amount_out }); - } - - // C2 internal rebalance: nobody outside Relay was paid, so the token coming back to the - // collector is the output rather than a fee. - let net_recv: Vec<(Address, U256)> = transfer_ledger - .group_net_received(fee_collectors) - .into_iter() - .collect(); - if net_recv.len() != 1 || net_recv[0].0 == token_in { - return None; - } - let (token_out, amount_out) = net_recv[0]; - Some(NetSwap { token_in, amount_in, token_out, amount_out }) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use alloy::{ - providers::RootProvider, - rpc::{client::RpcClient, types::Log}, - transports::mock::Asserter, - }; - - use super::*; - use crate::decoder::{ - decode::GasScope, - registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, - }; - - fn transfer_ledger(logs: &[Log], native: &[(Address, Address, U256)]) -> TransferLedger { - TransferLedger::from_transaction(logs, native) - } - - fn relay_collector(registry: &Registry) -> Address { - *registry - .venue("relay") - .unwrap() - .fee_collectors - .iter() - .next() - .unwrap() - } - - /// Decode a Relay transaction through the full `RelayNetting` decoder. - async fn decode( - registry: &Registry, - ledger: &TransferLedger, - sender: Address, - entry_point: Address, - ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); - let receipt = receipt(tx_hash(1), sender, Some(entry_point), vec![]); - let root = frame("CALL", sender, entry_point, 0); - let mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input: &[], - root: &root, - venue: registry.venue("relay"), - }; - RelayNetting.decode(&mut ctx).await - } - - #[test] - fn test_rebalance_external_token_fill() { - let fee = addr(99); - let pool = addr(50); - let recipient = addr(7); - let token_in = addr(10); - let token_out = addr(11); - let collectors = HashSet::from([fee]); - let routers = HashSet::from([addr(2)]); - let logs = vec![ - make_transfer_log(token_in, fee, pool, U256::from(1000)), - make_transfer_log(token_out, pool, recipient, U256::from(2000)), - ]; - let got = decode_rebalance(&transfer_ledger(&logs, &[]), &collectors, &routers, addr(200)) - .unwrap(); - assert_eq!(got, swap(token_in, 1000, token_out, 2000)); - } - - #[test] - fn test_rebalance_external_native_eth_out() { - let fee = addr(99); - let pool = addr(50); - let recipient = addr(7); - let token_in = addr(10); - let collectors = HashSet::from([fee]); - let routers = HashSet::from([addr(2)]); - let logs = vec![make_transfer_log(token_in, fee, pool, U256::from(1000))]; - let native = vec![(pool, recipient, U256::from(2000))]; - let got = - decode_rebalance(&transfer_ledger(&logs, &native), &collectors, &routers, addr(200)) - .unwrap(); - assert_eq!(got, swap(token_in, 1000, Address::ZERO, 2000)); - } - - #[test] - fn test_rebalance_internal_back_to_collector() { - let fee = addr(99); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - let collectors = HashSet::from([fee]); - let routers = HashSet::from([addr(2)]); - let logs = vec![ - make_transfer_log(token_in, fee, pool, U256::from(1000)), - make_transfer_log(token_out, pool, fee, U256::from(1001)), - ]; - let got = decode_rebalance(&transfer_ledger(&logs, &[]), &collectors, &routers, addr(200)) - .unwrap(); - assert_eq!(got, swap(token_in, 1000, token_out, 1001)); - } - - #[test] - fn test_rebalance_prefers_the_payee_over_the_collector_fee() { - // The shape of tx 0x65286b53…: the collector funds the input and also takes its fee in the - // output token, while the user is paid the rest. The trade is the user's payout; reading - // the collector's fee as the output valued a 0.0255 ETH fill at 0.000536 ETH. - let fee = addr(99); - let pool = addr(50); - let user = addr(7); - let token_in = addr(10); - let collectors = HashSet::from([fee]); - let routers = HashSet::from([addr(2)]); - let logs = vec![make_transfer_log(token_in, fee, pool, U256::from(1000))]; - let native = vec![(pool, user, U256::from(2000)), (pool, fee, U256::from(42))]; - let got = - decode_rebalance(&transfer_ledger(&logs, &native), &collectors, &routers, addr(200)) - .unwrap(); - assert_eq!(got, swap(token_in, 1000, Address::ZERO, 2000)); - } - - #[test] - fn test_rebalance_skips_a_fee_sized_output_leg() { - // The shape of tx 0x3dc3f83e…: the executor splits the bought token 0.1% to a fee address - // and 99.9% to the payee, who also forwarded the input and is therefore not a pure sink. - // With only the fee address left as a candidate, its sliver was read as the whole output. - let fee_collector = addr(99); - let executor = addr(50); - let payee = addr(7); - let fee_recipient = addr(8); - let token_in = addr(10); - let token_out = addr(11); - let collectors = HashSet::from([fee_collector]); - let routers = HashSet::from([addr(2)]); - let logs = vec![ - make_transfer_log(token_in, fee_collector, payee, U256::from(1000)), - make_transfer_log(token_in, payee, executor, U256::from(1000)), - make_transfer_log(token_out, executor, fee_recipient, U256::from(1)), - make_transfer_log(token_out, executor, payee, U256::from(999)), - ]; - assert!(decode_rebalance(&transfer_ledger(&logs, &[]), &collectors, &routers, addr(200)) - .is_none()); - } - - #[test] - fn test_rebalance_keeps_a_full_size_output_leg() { - // The same shape without the fee split: the sink recipient holds the whole output, so it - // is the fill and still decodes. - let fee_collector = addr(99); - let pool = addr(50); - let recipient = addr(7); - let token_in = addr(10); - let token_out = addr(11); - let collectors = HashSet::from([fee_collector]); - let routers = HashSet::from([addr(2)]); - let logs = vec![ - make_transfer_log(token_in, fee_collector, pool, U256::from(1000)), - make_transfer_log(token_out, pool, recipient, U256::from(999)), - ]; - let got = decode_rebalance(&transfer_ledger(&logs, &[]), &collectors, &routers, addr(200)) - .unwrap(); - assert_eq!(got, swap(token_in, 1000, token_out, 999)); - } - - #[test] - fn test_rebalance_multi_recipient() { - let fee = addr(99); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - let collectors = HashSet::from([fee]); - let routers = HashSet::from([addr(2)]); - let logs = vec![ - make_transfer_log(token_in, fee, pool, U256::from(2000)), - make_transfer_log(token_out, pool, addr(7), U256::from(1000)), - make_transfer_log(token_out, pool, addr(8), U256::from(1000)), - ]; - assert!(decode_rebalance(&transfer_ledger(&logs, &[]), &collectors, &routers, addr(200)) - .is_none()); - } - - #[test] - fn test_rebalance_unconverted_payout() { - // Live tx 0x455f5202…: the collector pays out its token unconverted to an external - // recipient (cross-chain order settled from same-token inventory) plus a tiny native gas - // top-up. Pairing the top-up as "the output" fabricated a 10-million-bps win — a payout - // has no conversion to re-solve and must decline. - let fee = addr(99); - let router = addr(2); - let recipient = addr(7); - let gas_recipient = addr(8); - let token_in = addr(10); - let collectors = HashSet::from([fee]); - let routers = HashSet::from([router]); - let logs = vec![ - make_transfer_log(token_in, fee, router, U256::from(2_002_781_016u64)), - make_transfer_log(token_in, router, recipient, U256::from(2_002_781_016u64)), - ]; - let native = vec![(router, gas_recipient, U256::from(1_139_527_584_556_489u64))]; - assert!(decode_rebalance( - &transfer_ledger(&logs, &native), - &collectors, - &routers, - addr(200) - ) - .is_none()); - } - - #[test] - fn test_rebalance_without_collector_outflow() { - let logs = vec![make_transfer_log(addr(10), addr(1), addr(50), U256::from(1000))]; - let collectors = HashSet::from([addr(99)]); - let routers = HashSet::from([addr(2)]); - assert!(decode_rebalance(&transfer_ledger(&logs, &[]), &collectors, &routers, addr(200)) - .is_none()); - } - - #[tokio::test] - async fn test_user_flow_with_fee() { - // User swap through Relay: sender nets token_in -> token_out, with an input-side fee to - // the real Relay collector. The fee is backed out of amount_in. - let registry = Registry::ethereum(); - let collector = relay_collector(®istry); - let user = addr(1); - let router = addr(2); - let pool = addr(50); - let token_in = addr(10); - let token_out = addr(11); - - let logs = vec![ - make_transfer_log(token_in, user, router, U256::from(1000)), - make_transfer_log(token_in, router, collector, U256::from(40)), - make_transfer_log(token_in, router, pool, U256::from(960)), - make_transfer_log(token_out, pool, user, U256::from(2000)), - ]; - let flow = decode(®istry, &transfer_ledger(&logs, &[]), user, router) - .await - .unwrap(); - assert_eq!(flow.tracked, user); - assert_eq!(flow.swap, swap(token_in, 960, token_out, 2000)); - assert_eq!(flow.venue_fee_in, Some(U256::from(40))); - assert_eq!(flow.venue_fee_out, None); - assert_eq!(flow.gas_scope, GasScope::SolverFrame); - } - - #[tokio::test] - async fn test_collector_is_the_trader() { - // Treasury op (live tx 0x80a4c0…): the fee collector itself unwraps WETH via the router. - // Its 1:1 native receipt must not be treated as a fee and added back — that doubled the - // output. - let registry = Registry::ethereum(); - let collector = relay_collector(®istry); - let router = addr(2); - let weth = addr(10); - - let logs = vec![make_transfer_log(weth, collector, router, U256::from(1000))]; - let native = vec![(router, collector, U256::from(1000))]; - - let flow = decode(®istry, &transfer_ledger(&logs, &native), collector, router) - .await - .unwrap(); - assert_eq!(flow.tracked, collector); - assert_eq!(flow.swap, swap(weth, 1000, Address::ZERO, 1000)); - assert_eq!(flow.venue_fee_in, None); - assert_eq!(flow.venue_fee_out, None); - } - - #[tokio::test] - async fn test_rebalance_fill() { - // Solver fill: the sender has no net flow; the collector funds the swap. No fee back-out. - let registry = Registry::ethereum(); - let collector = relay_collector(®istry); - let solver = addr(1); - let router = addr(2); - let pool = addr(50); - let recipient = addr(7); - let token_in = addr(10); - let token_out = addr(11); - - let logs = vec![ - make_transfer_log(token_in, collector, pool, U256::from(1000)), - make_transfer_log(token_out, pool, recipient, U256::from(2000)), - ]; - - let flow = decode(®istry, &transfer_ledger(&logs, &[]), solver, router) - .await - .unwrap(); - assert_eq!(flow.tracked, solver); - assert_eq!(flow.swap, swap(token_in, 1000, token_out, 2000)); - assert_eq!(flow.venue_fee_in, None); - assert_eq!(flow.venue_fee_out, None); - assert_eq!(flow.gas_scope, GasScope::NotCharged); - } - - mod relay_calldata { - use alloy::{primitives::address, rpc::types::trace::geth::CallFrame}; - - use super::*; - - /// Fly's own router — same address on every chain (`docs.fly.trade`). - const FLY: Address = address!("0x20f6ee51340adeed01a59b0e65cb3703f3dc860c"); - /// 0x's `AllowanceHolder` — a registered solver with no `swap_intent` support. - const ZEROX: Address = address!("0xdef1c0ded9bec7f1a1670819833240f027b25eff"); - /// Relay's own router — in the live fixture this is both the entry point and the - /// declared output recipient Fly's calldata carries (Relay receives and forwards). - const ROUTER: Address = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); - - /// The real Fly calldata used by `solvers::fly`'s fixture tests: USDT in, native out, - /// `amount_in` 19,694,643, `min_amount_out` 10,217,898,321,149,381, declared quote - /// 10,321,109,415,302,405. - fn fly_input() -> Vec { - let text = include_str!("../solvers/fixtures/fly_input.txt").trim(); - alloy::hex::decode(text.strip_prefix("0x").unwrap_or(text)).unwrap() - } - - const TOKEN_IN: Address = address!("0xfde4c96c8593536e31f229ea8f37b2ada2699bb2"); - const AMOUNT_IN: u64 = 19_694_643; - const MIN_AMOUNT_OUT: u128 = 10_217_898_321_149_381; - const QUOTED_AMOUNT_OUT: u128 = 10_321_109_415_302_405; - - /// A root frame: `sender -> router -> solver`, the solver frame carrying `input`. - fn root_with_solver_frame(sender: Address, router: Address, solver: Address) -> CallFrame { - let mut solver_call = frame("CALL", router, solver, 0); - solver_call.input = fly_input().into(); - let mut root = frame("CALL", sender, router, 0); - root.calls = vec![solver_call]; - root - } - - async fn decode_calldata( - registry: &Registry, - root: &CallFrame, - ledger: &TransferLedger, - sender: Address, - router: Address, - ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); - let receipt = receipt(tx_hash(1), sender, Some(router), vec![]); - let mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point: router, - transfer_ledger: ledger, - input: &[], - root, - venue: registry.venue("relay"), - }; - RelayCalldata.decode(&mut ctx).await - } - - #[tokio::test] - async fn test_decode_recovers_output_from_recipient_receipt() { - // The router — the declared recipient — receives native ETH above the floor; the - // sender pays the input token directly (sender-funded). - let registry = Registry::ethereum(); - let sender = addr(1); - let root = root_with_solver_frame(sender, ROUTER, FLY); - let logs = vec![make_transfer_log(TOKEN_IN, sender, ROUTER, U256::from(AMOUNT_IN))]; - let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; - let ledger = TransferLedger::from_transaction(&logs, &native); - - let flow = decode_calldata(®istry, &root, &ledger, sender, ROUTER) - .await - .unwrap(); - assert_eq!(flow.tracked, sender); - assert_eq!(flow.swap.token_in, TOKEN_IN); - assert_eq!(flow.swap.token_out, Address::ZERO); - assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); - assert_eq!(flow.swap.amount_out, U256::from(MIN_AMOUNT_OUT + 1_000)); - assert_eq!(flow.gas_scope, GasScope::SolverFrame); - } - - #[tokio::test] - async fn test_decode_below_floor_declines() { - // The recipient's receipt sits under the intent's on-chain floor: a successful trade - // clears its floor by construction, so this means the query mis-attributed. - let registry = Registry::ethereum(); - let sender = addr(1); - let root = root_with_solver_frame(sender, ROUTER, FLY); - let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT - 1))]; - let ledger = TransferLedger::from_transaction(&[], &native); - - assert!(decode_calldata(®istry, &root, &ledger, sender, ROUTER) - .await - .is_none()); - } - - #[tokio::test] - async fn test_decode_no_recipient_receipt_declines() { - let registry = Registry::ethereum(); - let sender = addr(1); - let root = root_with_solver_frame(sender, ROUTER, FLY); - let ledger = TransferLedger::from_transaction(&[], &[]); - - assert!(decode_calldata(®istry, &root, &ledger, sender, ROUTER) - .await - .is_none()); - } - - #[tokio::test] - async fn test_decode_no_solver_frame_declines() { - let registry = Registry::ethereum(); - let sender = addr(1); - let root = frame("CALL", sender, ROUTER, 0); - let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; - let ledger = TransferLedger::from_transaction(&[], &native); - - assert!(decode_calldata(®istry, &root, &ledger, sender, ROUTER) - .await - .is_none()); - } - - #[tokio::test] - async fn test_decode_solver_without_intent_support_declines() { - // 0x is a registered solver (matches `find_solver_frame`) but has no `swap_intent` - // implementation: the calldata path has nothing to recover, so it falls through. - let registry = Registry::ethereum(); - let sender = addr(1); - let root = root_with_solver_frame(sender, ROUTER, ZEROX); - let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; - let ledger = TransferLedger::from_transaction(&[], &native); - - assert!(decode_calldata(®istry, &root, &ledger, sender, ROUTER) - .await - .is_none()); - } - - #[tokio::test] - async fn test_decode_implausible_quote_declines() { - // A recovered output more than 2x the declared quote: `plausible_quote`'s band would - // reject it as a unit mismatch or a mis-attributed receipt, even though it clears the - // floor comfortably. - let registry = Registry::ethereum(); - let sender = addr(1); - let root = root_with_solver_frame(sender, ROUTER, FLY); - let implausible = U256::from(QUOTED_AMOUNT_OUT) * U256::from(3u64); - let native = vec![(addr(50), ROUTER, implausible)]; - let ledger = TransferLedger::from_transaction(&[], &native); - - assert!(decode_calldata(®istry, &root, &ledger, sender, ROUTER) - .await - .is_none()); - } - - #[tokio::test] - async fn test_decode_collector_funded_is_not_charged_gas() { - // The fee collector, not the sender, net-sends the input token: a solver-initiated - // rebalance, which charges no gas to any trader. - let registry = Registry::ethereum(); - let sender = addr(1); - let collector = relay_collector(®istry); - let root = root_with_solver_frame(sender, ROUTER, FLY); - let logs = vec![make_transfer_log(TOKEN_IN, collector, ROUTER, U256::from(AMOUNT_IN))]; - let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; - let ledger = TransferLedger::from_transaction(&logs, &native); - - let flow = decode_calldata(®istry, &root, &ledger, sender, ROUTER) - .await - .unwrap(); - assert_eq!(flow.gas_scope, GasScope::NotCharged); - } - - #[tokio::test] - async fn test_decode_records_venue_fee_without_adjusting_amounts() { - // An input-side fee leg to the real Relay collector: recorded for transparency, but - // `amount_in` stays the intent's raw figure — it is already post-fee (§1 of the design - // doc), unlike netting's fee back-out. - let registry = Registry::ethereum(); - let sender = addr(1); - let collector = relay_collector(®istry); - let root = root_with_solver_frame(sender, ROUTER, FLY); - let logs = vec![ - make_transfer_log(TOKEN_IN, sender, ROUTER, U256::from(AMOUNT_IN)), - make_transfer_log(TOKEN_IN, ROUTER, collector, U256::from(40)), - ]; - let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; - let ledger = TransferLedger::from_transaction(&logs, &native); - - let flow = decode_calldata(®istry, &root, &ledger, sender, ROUTER) - .await - .unwrap(); - assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); - assert_eq!(flow.venue_fee_in, Some(U256::from(40))); - } - } -} diff --git a/tools/hindsight/src/decoder/veto.rs b/tools/hindsight/src/decoder/veto.rs index 18a3c3dcc..ed82596c0 100644 --- a/tools/hindsight/src/decoder/veto.rs +++ b/tools/hindsight/src/decoder/veto.rs @@ -1,9 +1,9 @@ //! Vetoes: rejections that keep non-trades out of the records, and the `Veto` type they all //! share. //! -//! There are two veto points. Solver-specific vetoes run at match time on logs alone (see -//! `solvers::solver_veto`), before a transaction costs a trace. The checks in this module run -//! after decoding: netting can pair value legs that were never a swap — the payment side of an +//! There are two veto points. A solver vetoes from its own `SolverDecoder::declared`, which the +//! settling solver answers before anything is decoded. The checks in this module run after +//! decoding: netting can pair value legs that were never a swap — the payment side of an //! NFT purchase, or a cross-chain deposit's dust refund. Each check recognizes one such shape //! from the transaction itself (no prices, no external data); `check` is the single entry //! point the decoder runs on every decoded flow, so adding a check never touches the @@ -17,9 +17,8 @@ use alloy::{ }; use crate::decoder::{ - decode::TraderFlow, registry::Registry, - transfer_ledger::{NetSwap, Transfer, TransferLedger, RESIDUE_GROSS_RATIO}, + transfer_ledger::{SettledSwap, Transfer, TransferLedger, RESIDUE_GROSS_RATIO}, }; /// A transaction rejected as not a comparable trade, by the shape that disqualified it. @@ -34,9 +33,18 @@ pub(crate) enum Veto { /// mis-paired cross-chain deposit whose only same-chain receipt is a dust remainder refund. MispairedWrapPair, /// A cross-chain bridge order settled by a solver router: the real output lands on the - /// destination chain, so there is no same-chain swap to record. Placed at match time by - /// `solvers::solver_veto`, never by `check`. + /// destination chain, so there is no same-chain swap to record. Returned by the settling + /// solver's own `SolverDecoder::declared`, never by `check`. BridgeOrder, + /// The settling solver's calldata named the output token and the address it is paid to, but + /// that address received none of it, or less than the floor the same calldata enforces. The + /// trade cannot be read from the source that named it, and netting would answer a different + /// question, so the transaction is dropped. Returned by `declared::declared_flow`. + OutputNotFound, + /// The settling solver's calldata fixed the output and only bounded the input, and the sender + /// paid none of the input token, or more than that bound. Same reasoning as `OutputNotFound` + /// on the other side of the trade. Returned by `declared::declared_flow`. + InputNotFound, /// Part of the trade's value was taken by a fee on transfer: the token contract (or an /// unregistered fee) split a transfer, landing a significant share on an address that only /// accumulates — its fee wallet. Selling such a token, the fee nets into `amount_in`; @@ -47,20 +55,51 @@ pub(crate) enum Veto { FeeOnTransfer, } +/// A shortfall this far below the declared input is the token taking a cut, not rounding: one +/// basis point of the amount. +const TAX_DETECTION_BPS: u64 = 10_000; + +/// Whether the trader delivered less of the input token than the trade's own `amount_in` says. +/// +/// A normal ERC-20 transfer of X delivers X, so a shortfall means the token took a cut in transit. +/// Tycho labels these tokens quality 100 and quotes them as if transfers were free, so a re-solve +/// computes the output for the full amount while the pools only ever received the taxed +/// remainder — the comparison would credit Fynd with the token's own fee. Seen on `EverRise` +/// (RISE), which delivered 0.95 of every 1.0 its Universal Router calldata authorised. +/// +/// A trader who sent none of the input token funded the swap some other way (a solver rebalance, +/// a third-party funder), which this cannot judge, so it is not a veto. +fn input_short_of_declared(flow: &SettledSwap, transfer_ledger: &TransferLedger) -> bool { + let sent = transfer_ledger.sent_by_address(flow.tracked, flow.token_in); + if sent.is_zero() || sent >= flow.amount_in { + return false; + } + let shortfall = flow.amount_in - sent; + shortfall.saturating_mul(U256::from(TAX_DETECTION_BPS)) >= flow.amount_in +} + /// Check a decoded flow against every post-decode veto, returning the first. +/// +/// `payee` is the address the flow's output was anchored on — the declared output recipient, or +/// the tracked trader when nothing named one. The fee-on-transfer test needs it so a named payee +/// is not mistaken for a token's fee wallet. pub(crate) fn check( - flow: &TraderFlow, + flow: &SettledSwap, transfer_ledger: &TransferLedger, logs: &[Log], registry: &Registry, + payee: Address, ) -> Option { if received_nft(logs, flow.tracked) { return Some(Veto::NftPurchase); } - if wrap_pair_mispaired(&flow.swap, registry.wrapped_native()) { + if wrap_pair_mispaired(flow, registry.wrapped_native()) { return Some(Veto::MispairedWrapPair); } - if fee_on_transfer(flow, transfer_ledger, registry) { + if input_short_of_declared(flow, transfer_ledger) { + return Some(Veto::FeeOnTransfer); + } + if fee_on_transfer(flow, transfer_ledger, registry, payee) { return Some(Veto::FeeOnTransfer); } None @@ -72,16 +111,26 @@ pub(crate) fn check( /// quotes fee-free amounts, so the comparison would credit Fynd with the token's own fee. /// Registered venue fee collectors are exempt — their fees are backed out downstream. A leg /// under the residue line (1% of the trade amount, `RESIDUE_GROSS_RATIO`) is dust, not a fee. +/// +/// `payee` is exempt too: the address the trade's output was anchored on. Under netting the +/// tracked trader always both sent and received, so no anchor could look like a pure sink. A +/// declared decode can anchor on an address the solver's own calldata named as the payee +/// (`KyberSwap`'s `dstReceiver`, paid straight from the pool), which sends nothing and would +/// otherwise be read as a fee wallet collecting the entire output — discarding exactly the +/// different-receiver trades the declared read exists to reach. fn fee_on_transfer( - flow: &TraderFlow, + flow: &SettledSwap, transfer_ledger: &TransferLedger, registry: &Registry, + payee: Address, ) -> bool { - let sides = - [(flow.swap.token_in, flow.swap.amount_in), (flow.swap.token_out, flow.swap.amount_out)]; + let sides = [(flow.token_in, flow.amount_in), (flow.token_out, flow.amount_out)]; for (token, trade_amount) in sides { for (recipient, total) in transfer_ledger.sink_payments(token) { - if registry.is_fee_collector(recipient) || registry.is_infrastructure(recipient) { + if recipient == payee || + registry.is_fee_collector(recipient) || + registry.is_infrastructure(recipient) + { continue; } if total.saturating_mul(U256::from(RESIDUE_GROSS_RATIO)) >= trade_amount { @@ -144,7 +193,7 @@ const WRAP_PAIR_MAX_RATIO: u64 = 2; /// Seen with cross-chain deposits where the trader sends WETH and the only same-chain receipt is /// a dust remainder refund in native ETH — netting pairs the two into a trade that never happened, /// orders of magnitude off parity. -fn wrap_pair_mispaired(swap: &NetSwap, wrapped_native: Address) -> bool { +fn wrap_pair_mispaired(swap: &SettledSwap, wrapped_native: Address) -> bool { let pair = [swap.token_in, swap.token_out]; if !(pair.contains(&Address::ZERO) && pair.contains(&wrapped_native)) { return false; @@ -225,8 +274,8 @@ mod tests { assert!(!wrap_pair_mispaired(&swap(weth, 1000, Address::ZERO, 900), weth)); } - fn flow(tracked: Address, net: NetSwap) -> TraderFlow { - TraderFlow::without_fees(tracked, net) + fn flow(tracked: Address, net: SettledSwap) -> SettledSwap { + SettledSwap { tracked, ..net } } #[test] @@ -249,7 +298,7 @@ mod tests { let taxed = flow(trader, swap(token_in, 1000, token_out, 2000)); assert_eq!( - check(&taxed, &transfer_ledger, &logs, &Registry::ethereum()), + check(&taxed, &transfer_ledger, &logs, &Registry::ethereum(), trader), Some(Veto::FeeOnTransfer) ); } @@ -274,7 +323,89 @@ mod tests { let taxed = flow(trader, swap(token_in, 1000, token_out, 1900)); assert_eq!( - check(&taxed, &transfer_ledger, &logs, &Registry::ethereum()), + check(&taxed, &transfer_ledger, &logs, &Registry::ethereum(), trader), + Some(Veto::FeeOnTransfer) + ); + } + + #[test] + fn test_input_short_of_declared_is_a_fee_token() { + // EverRise (RISE), live tx 0x40235d0b…: the Universal Router calldata authorised 1.0 and + // the trader's transfer delivered 0.95. Tycho quotes RISE as fee-free, so the comparison + // would hand Fynd the token's 5% cut. + let trader = addr(1); + let pool = addr(50); + let rise = addr(10); + let logs = + vec![make_transfer_log(rise, trader, pool, U256::from(950_000_000_000_000_000u64))]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let declared = SettledSwap { + tracked: trader, + ..swap(rise, 1_000_000_000_000_000_000, Address::ZERO, 6_238_916_653) + }; + assert!(input_short_of_declared(&declared, &ledger)); + } + + #[test] + fn test_input_matching_the_transfer_is_not_a_fee_token() { + let trader = addr(1); + let token = addr(10); + let logs = vec![make_transfer_log(token, trader, addr(50), U256::from(1_000u64))]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let flow = SettledSwap { tracked: trader, ..swap(token, 1_000, addr(11), 2_000) }; + assert!(!input_short_of_declared(&flow, &ledger)); + } + + #[test] + fn test_input_larger_than_declared_is_a_fee_taken_before_the_swap() { + // ParaSwap's shape: the trader spends more than the calldata's amount_in because a fee + // was taken before the solver frame. The calldata figure is the amount that reached the + // pools, which is correct — not a taxed token. + let trader = addr(1); + let token = addr(10); + let logs = vec![make_transfer_log(token, trader, addr(50), U256::from(1_010u64))]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let flow = SettledSwap { tracked: trader, ..swap(token, 1_000, addr(11), 2_000) }; + assert!(!input_short_of_declared(&flow, &ledger)); + } + + #[test] + fn test_third_party_funded_input_is_not_judged() { + // The trader sent none of the input token — a solver rebalance or a third-party funder. + // Nothing here can tell whether the token taxes transfers, so it is not a veto. + let trader = addr(1); + let token = addr(10); + let logs = vec![make_transfer_log(token, addr(99), addr(50), U256::from(1_000u64))]; + let ledger = TransferLedger::from_transaction(&logs, &[]); + let flow = SettledSwap { tracked: trader, ..swap(token, 1_000, addr(11), 2_000) }; + assert!(!input_short_of_declared(&flow, &ledger)); + } + + #[test] + fn test_declared_payee_is_not_a_transfer_fee_sink() { + // A different-receiver trade, which only the declared read can reach: the pool pays the + // whole output straight to the payee the calldata named (KyberSwap's `dstReceiver`), who + // sends nothing. That payee looks exactly like a fee wallet collecting the entire output, + // so without the exemption the trade is discarded as a token tax. + let registry = Registry::ethereum(); + let trader = addr(1); + let payee = addr(2); + let pool = addr(50); + let token_in = addr(10); + let token_out = addr(11); + + let logs = vec![ + make_transfer_log(token_in, trader, pool, U256::from(1000)), + make_transfer_log(token_out, pool, payee, U256::from(2000)), + ]; + let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); + let delivered = flow(trader, swap(token_in, 1000, token_out, 2000)); + + assert_eq!(check(&delivered, &transfer_ledger, &logs, ®istry, payee), None); + // Anchored on the trader instead, the same payee is an unexplained sink — which is what + // the veto is for, and why the anchor has to be passed in rather than assumed. + assert_eq!( + check(&delivered, &transfer_ledger, &logs, ®istry, trader), Some(Veto::FeeOnTransfer) ); } @@ -285,10 +416,8 @@ mod tests { // decoders back out, not a token tax. let registry = Registry::ethereum(); let collector = *registry - .venue("metamask") - .unwrap() - .fee_collectors - .iter() + .venue_fees() + .keys() .next() .unwrap(); let trader = addr(1); @@ -304,7 +433,7 @@ mod tests { let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); let fee_paying = flow(trader, swap(token_in, 1000, token_out, 2000)); - assert_eq!(check(&fee_paying, &transfer_ledger, &logs, ®istry), None); + assert_eq!(check(&fee_paying, &transfer_ledger, &logs, ®istry, trader), None); } #[test] @@ -328,7 +457,10 @@ mod tests { let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); let partner_fee = flow(trader, swap(token_in, 1000, token_out, 1970)); - assert_eq!(check(&partner_fee, &transfer_ledger, &logs, &Registry::ethereum()), None); + assert_eq!( + check(&partner_fee, &transfer_ledger, &logs, &Registry::ethereum(), trader), + None + ); } #[test] @@ -349,7 +481,7 @@ mod tests { let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); let split = flow(trader, swap(token_in, 1000, token_out, 2000)); - assert_eq!(check(&split, &transfer_ledger, &logs, &Registry::ethereum()), None); + assert_eq!(check(&split, &transfer_ledger, &logs, &Registry::ethereum(), trader), None); } #[test] @@ -369,7 +501,7 @@ mod tests { let transfer_ledger = TransferLedger::from_transaction(&logs, &[]); let dusty = flow(trader, swap(token_in, 10_000, token_out, 20_000)); - assert_eq!(check(&dusty, &transfer_ledger, &logs, &Registry::ethereum()), None); + assert_eq!(check(&dusty, &transfer_ledger, &logs, &Registry::ethereum(), trader), None); } #[test] diff --git a/tools/hindsight/src/report/aggregate.rs b/tools/hindsight/src/report/aggregate.rs index 4af70cb15..b6aa703dc 100644 --- a/tools/hindsight/src/report/aggregate.rs +++ b/tools/hindsight/src/report/aggregate.rs @@ -89,7 +89,7 @@ pub(crate) struct GroupStats { pub wins: usize, pub losses: usize, pub unsolved: usize, - pub median_net_bps: Option, + pub median_bps: Option, pub total_improvement_usd: f64, } @@ -98,7 +98,7 @@ pub(crate) struct TradeRow { pub settled_tx: String, pub venue: String, pub solver: String, - pub net_bps: Option, + pub bps: Option, pub improvement_usd: f64, } @@ -168,7 +168,7 @@ fn savings(records: &[Comparison]) -> Savings { let mut win_bps: Vec = scored .iter() .filter(|r| r.top.verdict == "win") - .filter_map(|r| r.top.net_bps) + .filter_map(|r| r.top.raw_bps) .collect(); Savings { scored: scored.len(), @@ -192,10 +192,10 @@ fn group_stats(records: &[Comparison], key: impl Fn(&Comparison) -> &String) -> let mut stats: Vec = groups .into_iter() .map(|(name, group)| { - let mut net_bps: Vec = group + let mut bps: Vec = group .iter() .filter(|r| r.top.is_scored() && above_dust_floor(&r.top)) - .filter_map(|r| r.top.net_bps) + .filter_map(|r| r.top.raw_bps) .collect(); GroupStats { name: name.clone(), @@ -212,7 +212,7 @@ fn group_stats(records: &[Comparison], key: impl Fn(&Comparison) -> &String) -> .iter() .filter(|r| !r.top.is_served()) .count(), - median_net_bps: median(&mut net_bps), + median_bps: median(&mut bps), total_improvement_usd: group .iter() .filter(|r| r.top.is_scored()) @@ -260,7 +260,7 @@ fn trade_rows(records: &[Comparison], verdict: &str) -> Vec { settled_tx: r.settled_tx.clone(), venue: r.venue.clone(), solver: r.solver.clone(), - net_bps: r.top.net_bps, + bps: r.top.raw_bps, improvement_usd: usd, }) }) @@ -327,7 +327,7 @@ mod tests { "token_out": "0xbbb", "top": { "verdict": verdict, - "net_bps": bps, + "raw_bps": bps, "improvement_usd": bps.map(|b| b / 10.0), "settled_value_usd": 1000.0, }, @@ -380,7 +380,7 @@ mod tests { record_worth(2, "win", 9000.0, Some(0.5)), ]; let groups = group_stats(&records, |r| &r.venue); - assert_eq!(groups[0].median_net_bps, Some(10.0)); + assert_eq!(groups[0].median_bps, Some(10.0)); // The verdict counts still describe every trade in the group. assert_eq!(groups[0].count, 2); assert_eq!(groups[0].wins, 2); diff --git a/tools/hindsight/src/report/html.rs b/tools/hindsight/src/report/html.rs index 2976e2656..1b35e781a 100644 --- a/tools/hindsight/src/report/html.rs +++ b/tools/hindsight/src/report/html.rs @@ -223,7 +223,7 @@ fn group_section(title: &str, groups: &[GroupStats]) -> String { group.losses, group.unsolved, pct(group.wins, scored), - fmt_bps(group.median_net_bps), + fmt_bps(group.median_bps), fmt_usd(group.total_improvement_usd), ); } @@ -234,7 +234,7 @@ fn group_section(title: &str, groups: &[GroupStats]) -> String { fn trades_section(title: &str, trades: &[TradeRow]) -> String { let mut table = String::from( "\ - ", + ", ); for trade in trades { let _ = write!( @@ -244,7 +244,7 @@ fn trades_section(title: &str, trades: &[TradeRow]) -> String { escape(&short_hash(&trade.settled_tx)), escape(&trade.venue), escape(&trade.solver), - fmt_bps(trade.net_bps), + fmt_bps(trade.bps), fmt_usd(trade.improvement_usd), ); } @@ -423,12 +423,12 @@ mod tests { serde_json::json!({ "block": 1, "settled_tx": "0xabc0000000000000000000000000000000000000000000000000000000000001", "venue": "relay", "solver": "1inch", "token_in": "0xaaa", "token_out": "0xbbb", - "top": {"verdict": "win", "net_bps": 20.0, "improvement_usd": 12.0, "settled_value_usd": 1000.0} + "top": {"verdict": "win", "raw_bps": 20.0, "improvement_usd": 12.0, "settled_value_usd": 1000.0} }), serde_json::json!({ "block": 2, "settled_tx": "0xdef0000000000000000000000000000000000000000000000000000000000002", "venue": "relay", "solver": "0x", "token_in": "0xccc", "token_out": "0xddd", - "top": {"verdict": "unsolvable", "net_bps": null, "improvement_usd": null, "settled_value_usd": 50.0} + "top": {"verdict": "unsolvable", "raw_bps": null, "improvement_usd": null, "settled_value_usd": 50.0} }), ] .into_iter() diff --git a/tools/hindsight/src/report/mod.rs b/tools/hindsight/src/report/mod.rs index 174d80240..f1869fc87 100644 --- a/tools/hindsight/src/report/mod.rs +++ b/tools/hindsight/src/report/mod.rs @@ -17,7 +17,7 @@ use std::{ use anyhow::{bail, Context}; use tracing::{info, warn}; -use crate::report::record::Comparison; +use crate::{decoder::DecodeTier, report::record::Comparison}; /// Inputs for the `report` subcommand. #[derive(clap::Args)] @@ -33,6 +33,12 @@ pub(crate) struct ReportArgs { /// Only report trades from these venues (repeatable, case-insensitive). Omit for all venues. #[arg(long)] pub venue: Vec, + + /// Include netted records — trades whose amounts came from balance netting rather than the + /// solver's own calldata or logs, so an unaccounted fee can sit inside them. Excluded by + /// default; datasets recorded before the marker existed are always included. + #[arg(long)] + pub include_netted: bool, } /// Read the comparisons, aggregate them, and write the HTML report. @@ -41,7 +47,8 @@ pub(crate) fn run(args: ReportArgs) -> anyhow::Result<()> { if all.is_empty() { bail!("no comparison records found in {}", args.comparisons_dir.display()); } - let records = filter_by_venue(all, &args.venue)?; + let records = filter_netted(all, args.include_netted); + let records = filter_by_venue(records, &args.venue)?; let report = aggregate::build(&records); let filter = (!args.venue.is_empty()).then(|| args.venue.join(", ")); let html = html::render(&report, filter.as_deref()); @@ -54,6 +61,18 @@ pub(crate) fn run(args: ReportArgs) -> anyhow::Result<()> { Ok(()) } +/// Drop the marked netted records unless `include_netted` asks for them. A record with no +/// `decode` column predates the marker and is kept either way. +fn filter_netted(records: Vec, include_netted: bool) -> Vec { + if include_netted { + return records; + } + records + .into_iter() + .filter(|record| record.decode.as_deref() != Some(DecodeTier::Netted.wire())) + .collect() +} + /// Keep only the records whose venue is in `venues` (case-insensitive); all records when `venues` /// is empty. Errors with the available venue list when the filter matches nothing, so a typo is /// obvious rather than yielding a blank report. @@ -144,7 +163,7 @@ mod tests { serde_json::json!({ "block": block, "settled_tx": format!("0x{block:064x}"), "venue": "relay", "solver": "1inch", "token_in": "0xaaa", "token_out": "0xbbb", - "top": {"verdict": verdict, "net_bps": 1.0, "improvement_usd": 1.0, "settled_value_usd": 1.0}, + "top": {"verdict": verdict, "raw_bps": 1.0, "improvement_usd": 1.0, "settled_value_usd": 1.0}, }) .to_string() } @@ -182,11 +201,41 @@ mod tests { serde_json::from_value(serde_json::json!({ "block": 1, "settled_tx": "0x1", "venue": venue, "solver": "1inch", "token_in": "0xaaa", "token_out": "0xbbb", - "top": {"verdict": "win", "net_bps": 1.0, "improvement_usd": 1.0, "settled_value_usd": 1.0}, + "top": {"verdict": "win", "raw_bps": 1.0, "improvement_usd": 1.0, "settled_value_usd": 1.0}, })) .unwrap() } + fn decode_record(decode: Option<&str>) -> Comparison { + let mut record = serde_json::json!({ + "block": 1, "settled_tx": "0x1", "venue": "relay", "solver": "1inch", + "token_in": "0xaaa", "token_out": "0xbbb", + "top": {"verdict": "win", "raw_bps": 1.0, "improvement_usd": 1.0, "settled_value_usd": 1.0}, + }); + if let Some(decode) = decode { + record["decode"] = decode.into(); + } + serde_json::from_value(record).unwrap() + } + + #[test] + fn test_filter_netted() { + let records = || { + vec![ + decode_record(Some("declared")), + decode_record(Some("netted")), + // Recorded before the marker existed: no column, kept either way. + decode_record(None), + ] + }; + let kept = filter_netted(records(), false); + assert_eq!(kept.len(), 2); + assert!(kept + .iter() + .all(|record| record.decode.as_deref() != Some("netted"))); + assert_eq!(filter_netted(records(), true).len(), 3); + } + #[test] fn test_filter_by_venue() { let records = vec![venue_record("relay"), venue_record("metamask"), venue_record("relay")]; diff --git a/tools/hindsight/src/report/record.rs b/tools/hindsight/src/report/record.rs index 2cb233ab0..eb7c04f33 100644 --- a/tools/hindsight/src/report/record.rs +++ b/tools/hindsight/src/report/record.rs @@ -15,6 +15,12 @@ pub(crate) struct Comparison { pub settled_tx: String, pub venue: String, pub solver: String, + /// How the settled amounts were read: `"declared"` or `"netted"` (see the decoder's + /// `DecodedTrade`). Absent on datasets recorded before the column existed; those records were + /// all netted, but predate the marker, so they are kept unless `--include-netted` says + /// otherwise is wanted — `None` here means "no marker to filter on". + #[serde(default)] + pub decode: Option, pub token_in: String, pub token_out: String, /// Optimistic state (N-1); the report's headline, matching the monitor's headline verdict. @@ -26,7 +32,7 @@ pub(crate) struct Comparison { pub(crate) struct State { pub verdict: String, #[serde(default)] - pub net_bps: Option, + pub raw_bps: Option, /// Signed USD delta of Fynd's output vs the settled output — negative on a loss. Present only /// for a solved state. #[serde(default)] @@ -58,7 +64,7 @@ mod tests { use super::*; use crate::{ - decoder::{AttributionSource, DecodedTrade, Registry}, + decoder::{AttributionSource, DecodeSource, DecodeTier, DecodedTrade, Registry}, resolve::{build_range, jsonl::write_comparisons, Outcome, SolvedAmount}, usd::Prices, }; @@ -84,15 +90,13 @@ mod tests { venue: "relay".into(), solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender: Address::ZERO, token_in: weth, token_out: usdc, amount_in: U256::from(1_000u64), amount_out: U256::from(1_000_000_000u64), // settled 1000 USDC - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, @@ -109,7 +113,6 @@ mod tests { }); let range = build_range( &trade, - &prices, top, Outcome::Unsolvable("x".into()), &Outcome::Unsolvable("x".into()), @@ -125,7 +128,7 @@ mod tests { assert_eq!(record.solver, "1inch"); assert_eq!(record.top.verdict, "win"); assert!(record.top.is_scored()); - assert!(record.top.net_bps.unwrap() > 0.0); + assert!(record.top.raw_bps.unwrap() > 0.0); assert!((record.top.improvement_usd.unwrap() - 10.0).abs() < 1e-3); assert_eq!(record.token_out, format!("{usdc:#x}")); } diff --git a/tools/hindsight/src/resolve/compare.rs b/tools/hindsight/src/resolve/compare.rs index a4e70bbf5..d54322360 100644 --- a/tools/hindsight/src/resolve/compare.rs +++ b/tools/hindsight/src/resolve/compare.rs @@ -13,21 +13,16 @@ fn to_biguint(amount: U256) -> BigUint { BigUint::from_bytes_be(&amount.to_be_bytes::<32>()) } -/// Basis-point deltas of a Fynd quote against the settled amount (positive = Fynd better). +/// Basis-point delta of a Fynd quote against the settled amount (positive = Fynd better). #[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub(crate) struct Deltas { /// `fynd amount_out` vs the settled amount, both gross of gas — always like-for-like, and /// the basis of the headline `Verdict`. pub raw_bps: Option, - /// Secondary, recorded for later gas analysis: `fynd amount_out_net_gas` vs the settled - /// amount net of the gas the trader paid for it. Asymmetric when the settled gas is unknown - /// (the settled side stays gross while Fynd's is charged) — most Relay settlements are - /// operator-submitted so their trader gas is legitimately absent. Not used for verdicts. - pub net_bps: Option, } impl Deltas { - const NONE: Self = Self { raw_bps: None, net_bps: None }; + const NONE: Self = Self { raw_bps: None }; } /// Slippage of the top-of-block route re-executed at back-of-block: how the route's output moved @@ -75,6 +70,39 @@ pub(crate) enum Verdict { /// this is not a fair win or loss. The bps/USD deltas are still computed and written to /// JSONL for offline analysis; only the classification changes. Sandwiched, + /// Fynd's output is more than `MAX_WIN_RATIO` times the settled output, which no routing edge + /// produces: the settled amount this is measured against is not the trade's real output. Like + /// `Sandwiched`, the deltas are still written to JSONL so the record stays studyable, and the + /// classification keeps it out of the aggregates. + ImplausibleSettledAmount, +} + +/// The most output Fynd can produce against a settled trade before the settled amount itself is +/// the thing that must be wrong. +/// +/// A real routing edge is a few percent, occasionally tens of percent on a thin pair. Above two +/// orders of magnitude the comparison is not measuring routing: netting has paired the trade's +/// input with a dust receipt, so the settled output belongs to a different trade or to no trade. +/// Seen live on Ethereum: 3,555.90 USDC in, paired with 0.00000063 ETH out, reported as a +/// $3,555.81 win because Fynd correctly quoted 1.44 ETH. +/// +/// Measured over one staging day (2,527 solved-and-settled Ethereum records): the largest genuine +/// win was 2.66x and the smallest broken one 1,472,381x, so anything in that gap separates them. +/// 100x leaves both sides a wide margin. +const MAX_WIN_RATIO: f64 = 100.0; + +/// Whether Fynd's output is too far above the settled output for the settled amount to be real. +/// +/// Both amounts are in `token_out` units, so their ratio is unit-free and needs no prices. Only a +/// solved outcome can be judged, and a settled amount of zero is left to the coverage buckets — +/// there is no ratio to take. +pub(crate) fn implausible_settled_amount(outcome: &Outcome, settled_amount_out: U256) -> bool { + let Outcome::Solved(solved) = outcome else { + return false; + }; + let settled = usd::u256_to_f64(settled_amount_out); + let fynd = usd::u256_to_f64(solved.amount_out); + settled > 0.0 && fynd > MAX_WIN_RATIO * settled } /// Minimum fraction of the settled output Fynd must produce for the result to count as a real @@ -112,22 +140,13 @@ pub(crate) fn served(outcome: Outcome, settled_amount_out: U256) -> Outcome { outcome } -/// Compute raw and net-of-gas bps deltas of `outcome` against the settled trade. -/// -/// `raw_bps` compares gross outputs; `net_bps` compares both sides net of their own gas — -/// `settled_net_gas` is the settled output minus the gas the trader paid for the route, and -/// equals `settled_amount_out` when that gas is unknown or was paid by someone else. -pub(crate) fn compare( - outcome: &Outcome, - settled_amount_out: U256, - settled_net_gas: U256, -) -> Deltas { +/// Compute the gross bps delta of `outcome` against the settled trade. +pub(crate) fn compare(outcome: &Outcome, settled_amount_out: U256) -> Deltas { let Outcome::Solved(solved) = outcome else { return Deltas::NONE; }; Deltas { raw_bps: raw_bps_diff(&to_biguint(solved.amount_out), &to_biguint(settled_amount_out)), - net_bps: raw_bps_diff(&to_biguint(solved.amount_out_net_gas), &to_biguint(settled_net_gas)), } } @@ -137,7 +156,7 @@ pub(crate) fn compare( /// Gross-vs-gross is the one comparison that is always like-for-like: the settled route's gas is /// often legitimately unattributable (most Relay settlements are submitted by Relay's own /// operators, so the trader paid no gas), and a net-vs-gross fallback would mix comparison bases -/// across records. The net numbers are still recorded (`Deltas::net_bps`) for later analysis. +/// across records. pub(crate) fn verdict(outcome: &Outcome, deltas: &Deltas) -> Verdict { if let Outcome::Partial(_) = outcome { return Verdict::CoverageMiss; @@ -151,6 +170,40 @@ pub(crate) fn verdict(outcome: &Outcome, deltas: &Deltas) -> Verdict { #[cfg(test)] mod tests { + #[test] + fn test_implausible_settled_amount_dust_pairing() { + // Live staging record 0x24edb6ad…: 3,555.90 USDC in, which netting paired with a + // 0.00000063 ETH receipt. Fynd correctly quoted 1.44 ETH, so the record claimed a + // $3,555.81 win — 2.29 billion times the settled output. + let outcome = solved(1_443_961_056_940_373_096, 1_443_961_056_940_373_096); + assert!(implausible_settled_amount(&outcome, U256::from(630_533_677u64))); + } + + #[test] + fn test_largest_real_win_is_not_implausible() { + // The largest genuine win in the same staging day was 2.66x, far under the ceiling. + let outcome = solved(266, 266); + assert!(!implausible_settled_amount(&outcome, U256::from(100u64))); + } + + #[test] + fn test_ratio_either_side_of_the_ceiling() { + // 100x is allowed; past it the settled amount is the thing that must be wrong. + assert!(!implausible_settled_amount(&solved(100, 100), U256::from(1u64))); + assert!(implausible_settled_amount(&solved(101, 101), U256::from(1u64))); + } + + #[test] + fn test_zero_settled_and_unsolved_are_left_alone() { + // No ratio to take: a zero settled amount and an unsolved outcome belong to the coverage + // buckets, not here. + assert!(!implausible_settled_amount(&solved(1_000, 1_000), U256::ZERO)); + assert!(!implausible_settled_amount( + &Outcome::Unsolvable("no route".to_string()), + U256::from(1u64) + )); + } + use super::*; use crate::resolve::SolvedAmount; @@ -165,82 +218,53 @@ mod tests { }) } - /// Settled side without a known gas cost: net compares against the gross settled amount. - fn gross(settled: u64) -> (U256, U256) { - (U256::from(settled), U256::from(settled)) - } - #[test] fn test_compare_fynd_better() { - let (settled, net) = gross(10_000); - let d = compare(&solved(10_100, 10_050), settled, net); + let d = compare(&solved(10_100, 10_050), U256::from(10_000u64)); assert!((d.raw_bps.unwrap() - 100.0).abs() < 0.01); - assert!((d.net_bps.unwrap() - 50.0).abs() < 0.01); } #[test] fn test_compare_fynd_worse() { - let (settled, net) = gross(10_000); - let d = compare(&solved(9_900, 9_800), settled, net); + let d = compare(&solved(9_900, 9_800), U256::from(10_000u64)); assert!(d.raw_bps.unwrap() < 0.0); - assert!(d.net_bps.unwrap() < 0.0); - } - - #[test] - fn test_compare_with_settled_gas() { - // Settled 10_000 gross but its trader paid 100 in gas: raw still compares gross vs - // gross; net compares 10_050 vs 9_900. - let d = compare(&solved(10_100, 10_050), U256::from(10_000u64), U256::from(9_900u64)); - assert!((d.raw_bps.unwrap() - 100.0).abs() < 0.01); - assert!((d.net_bps.unwrap() - 151.5).abs() < 0.1); } #[test] fn test_compare_unsolvable() { - let (settled, net) = gross(10_000); - let d = compare(&Outcome::Unsolvable("no route".into()), settled, net); + let d = compare(&Outcome::Unsolvable("no route".into()), U256::from(10_000u64)); assert_eq!(d, Deltas::NONE); } #[test] fn test_compare_zero_settled() { - let d = compare(&solved(10_000, 10_000), U256::ZERO, U256::ZERO); + let d = compare(&solved(10_000, 10_000), U256::ZERO); assert_eq!(d.raw_bps, None); } #[test] fn test_verdict_win_threshold() { - let (settled, net) = gross(10_000); + let settled = U256::from(10_000u64); let outcome = solved(10_100, 10_050); - assert_eq!(verdict(&outcome, &compare(&outcome, settled, net)), Verdict::Win); + assert_eq!(verdict(&outcome, &compare(&outcome, settled)), Verdict::Win); } #[test] fn test_verdict_gross_better_net_worse() { - // Gross output wins even though Fynd's own gas would eat the edge: the headline verdict - // compares gross vs gross, and the net delta stays available as a secondary number. - let (settled, net) = gross(10_000); - let outcome = solved(10_100, 9_990); - assert_eq!(verdict(&outcome, &compare(&outcome, settled, net)), Verdict::Win); - } - - #[test] - fn test_verdict_with_settled_gas() { - // The settled trader's gas does not move the verdict in either direction — only the - // gross outputs do. - let fynd = solved(10_050, 9_990); + // Gross output wins even when Fynd's own gas would eat the edge: the verdict compares + // gross vs gross. let settled = U256::from(10_000u64); - assert_eq!(verdict(&fynd, &compare(&fynd, settled, settled)), Verdict::Win); - assert_eq!(verdict(&fynd, &compare(&fynd, settled, U256::from(9_900u64))), Verdict::Win); - let worse = solved(9_900, 9_800); - assert_eq!(verdict(&worse, &compare(&worse, settled, U256::from(9_000u64))), Verdict::Loss); + let outcome = solved(10_100, 9_990); + assert_eq!(verdict(&outcome, &compare(&outcome, settled)), Verdict::Win); } #[test] fn test_verdict_unsolvable() { - let (settled, net) = gross(10_000); let outcome = Outcome::Unsolvable("missing token".into()); - assert_eq!(verdict(&outcome, &compare(&outcome, settled, net)), Verdict::Unsolvable); + assert_eq!( + verdict(&outcome, &compare(&outcome, U256::from(10_000u64))), + Verdict::Unsolvable + ); } #[test] @@ -248,8 +272,10 @@ mod tests { // Fynd covered only 40% of the settled size → coverage miss, not a loss. let outcome = served(solved(400, 390), U256::from(1_000u64)); assert!(matches!(outcome, Outcome::Partial(_))); - let (settled, net) = gross(1_000); - assert_eq!(verdict(&outcome, &compare(&outcome, settled, net)), Verdict::CoverageMiss); + assert_eq!( + verdict(&outcome, &compare(&outcome, U256::from(1_000u64))), + Verdict::CoverageMiss + ); } #[test] diff --git a/tools/hindsight/src/resolve/jsonl.rs b/tools/hindsight/src/resolve/jsonl.rs index 1a1e53383..4bc75d2e6 100644 --- a/tools/hindsight/src/resolve/jsonl.rs +++ b/tools/hindsight/src/resolve/jsonl.rs @@ -168,12 +168,11 @@ fn comparison_record( "solver": range.solver, "solver_source": range.solver_source, "decoder": range.decoder, + "decode": range.decode, "token_in": format!("{:#x}", range.token_in), "token_out": format!("{:#x}", range.token_out), "amount_in": range.amount_in.to_string(), "settled_amount_out": range.settled_amount_out.to_string(), - "settled_amount_out_net_gas": range.settled_amount_out_net_gas.to_string(), - "settled_gas_cost": range.settled_gas.map(|gas| gas.to_string()), "min_amount_out": range.min_amount_out.map(|amount| amount.to_string()), "quoted_amount_out": range.declared_quote.map(|amount| amount.to_string()), "quote_timestamp": range.quote_timestamp, @@ -209,7 +208,6 @@ fn state_record( let fynd_value_usd = solved.and_then(|s| prices.value_usd(token_out, s.amount_out)); serde_json::json!({ "verdict": state.verdict, - "net_bps": state.deltas.net_bps, "raw_bps": state.deltas.raw_bps, "fynd_amount_out": solved.map(|s| s.amount_out.to_string()), "fynd_amount_out_net_gas": solved.map(|s| s.amount_out_net_gas.to_string()), @@ -281,7 +279,9 @@ mod tests { use super::*; use crate::{ - decoder::{AttributionSource, DecodedTrade, Registry, SandwichEvidence}, + decoder::{ + AttributionSource, DecodeSource, DecodeTier, DecodedTrade, Registry, SandwichEvidence, + }, resolve::{build_range, test_support, SolvedAmount}, }; @@ -334,15 +334,13 @@ mod tests { venue: "relay".into(), solver: "kyberswap".into(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender: Address::ZERO, token_in: Address::ZERO, token_out: Address::repeat_byte(0x22), amount_in: U256::from(1_000u64), amount_out: U256::from(69_996_280_564u64), - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: Some(U256::from(69_996_280_564u64)), declared_quote: Some(U256::from(70_400_409_935u64)), quote_timestamp: Some(1_783_421_726), @@ -350,7 +348,6 @@ mod tests { }; let range = build_range( &trade, - &empty_prices(), Outcome::Unsolvable("x".into()), Outcome::Unsolvable("x".into()), &Outcome::Unsolvable("x".into()), @@ -412,15 +409,13 @@ mod tests { venue: "relay".into(), solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender: Address::ZERO, token_in: weth, token_out: usdc, amount_in: U256::from(1_000u64), amount_out: U256::from(1_000_000_000u64), // settled 1000 USDC - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, @@ -456,7 +451,7 @@ mod tests { quote_json: quote, solved_route: Some(solved_route), }); - let range = build_range(&trade, &prices, top, back.clone(), &back); + let range = build_range(&trade, top, back.clone(), &back); comparison_record(&range, &prices, &prices) } @@ -491,7 +486,7 @@ mod tests { .unwrap(); assert!((slippage_usd + 8.0).abs() < 1e-3, "slippage_usd={slippage_usd}"); assert!( - rec.pointer("/back/net_bps") + rec.pointer("/back/raw_bps") .unwrap() .as_f64() .unwrap() > @@ -536,15 +531,13 @@ mod tests { venue: "relay".into(), solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender: Address::ZERO, token_in: Address::repeat_byte(0x11), token_out: Address::repeat_byte(0x22), amount_in: U256::from(1_000u64), amount_out: U256::from(1_000u64), - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, @@ -553,7 +546,6 @@ mod tests { // A coverage gap: Fynd could not solve at either state. let range = build_range( &trade, - &empty_prices(), Outcome::Unsolvable("missing token in Tycho".into()), Outcome::Unsolvable("missing token in Tycho".into()), &Outcome::Unsolvable("no top-of-block route to re-execute".into()), @@ -595,15 +587,13 @@ mod tests { venue: "relay".into(), solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender: Address::ZERO, token_in: Address::repeat_byte(0x11), token_out: Address::repeat_byte(0x22), amount_in: U256::from(1_000u64), amount_out: U256::from(1_000u64), - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, @@ -627,8 +617,7 @@ mod tests { solved_route: None, }) }; - let range = - build_range(&trade, &empty_prices(), solved(1_100), solved(1_050), &solved(1_050)); + let range = build_range(&trade, solved(1_100), solved(1_050), &solved(1_050)); let rec = comparison_record(&range, &empty_prices(), &empty_prices()); assert_eq!(rec.pointer("/tx_index").unwrap(), 42); diff --git a/tools/hindsight/src/resolve/mod.rs b/tools/hindsight/src/resolve/mod.rs index 94c874efa..ec923fa19 100644 --- a/tools/hindsight/src/resolve/mod.rs +++ b/tools/hindsight/src/resolve/mod.rs @@ -21,10 +21,7 @@ use fynd_core::types::{Route, Swap}; use serde::Serialize; use tycho_simulation::tycho_common::models::Address as CoreAddress; -use crate::{ - decoder::{AttributionSource, DecodedTrade, SandwichEvidence}, - usd::Prices, -}; +use crate::decoder::{AttributionSource, DecodeSource, DecodeTier, DecodedTrade, SandwichEvidence}; /// One route leg, reduced to what rendering needs. A `Swap` also carries a `ProtocolComponent` /// and a boxed `ProtocolSim` that a route string has no use for and that cannot be built outside @@ -212,9 +209,9 @@ pub(crate) struct StateResult { } impl StateResult { - fn new(outcome: Outcome, settled_amount_out: U256, settled_net_gas: U256) -> Self { + fn new(outcome: Outcome, settled_amount_out: U256) -> Self { let outcome = compare::served(outcome, settled_amount_out); - let deltas = compare::compare(&outcome, settled_amount_out, settled_net_gas); + let deltas = compare::compare(&outcome, settled_amount_out); let verdict = compare::verdict(&outcome, &deltas); Self { outcome, deltas, verdict } } @@ -231,17 +228,14 @@ pub(crate) struct RangeComparison { /// The evidence tier the solver label came from (from the decoder). pub solver_source: AttributionSource, /// Which decoder recovered the settled trade. - pub decoder: &'static str, + pub decoder: DecodeSource, + /// The tier `decoder` produces — declared (the solver's own calldata or logs) or netted + /// (balance netting, excluded from the report by default). + pub decode: DecodeTier, pub token_in: Address, pub token_out: Address, pub amount_in: U256, pub settled_amount_out: U256, - /// Settled output after the gas the trader paid for the route, in `token_out` units. Equals - /// `settled_amount_out` when that gas is unknown, was paid by someone else, or the output - /// token is unpriced. - pub settled_amount_out_net_gas: U256, - /// Wei cost of the settled route's gas, when the trader paid it (from the decoder). - pub settled_gas: Option, /// The on-chain enforced floor declared in the settling solver frame's own calldata (from /// the decoder). pub min_amount_out: Option, @@ -284,11 +278,6 @@ pub(crate) trait SteppingSolver { /// back-of-block solve, and the top route's re-execution at back-of-block (which feeds only the /// `slippage` field). /// -/// When the decoder isolated the gas the trader paid for the settled route, its cost is converted -/// into `token_out` units at the `prices` snapshot (top-of-block — a fine approximation for a gas -/// deduction) and subtracted from the settled output, so both sides of the net comparison carry -/// their own gas. -/// /// When the decoder flagged the trade as sandwiched, each *solved* state's verdict becomes /// `Verdict::Sandwiched`: its win or loss measures the MEV that moved the settled output, not /// routing quality. Unsolved states keep their verdicts — a sandwich explains the settled price, @@ -297,22 +286,21 @@ pub(crate) trait SteppingSolver { /// stays studyable offline. pub(crate) fn build_range( trade: &DecodedTrade, - prices: &Prices, top: Outcome, back: Outcome, reexecuted: &Outcome, ) -> RangeComparison { - let settled_net_gas = trade - .settled_gas - .and_then(|gas| prices.gas_in_token(gas, trade.token_out)) - .map_or(trade.amount_out, |gas_out| trade.amount_out.saturating_sub(gas_out)); // Computed from the raw outcomes: the coverage-miss reclassification below discards the // solved amounts the slippage is measured from. let slippage = compare::slippage(&top, reexecuted); - let mut top = StateResult::new(top, trade.amount_out, settled_net_gas); - let mut back = StateResult::new(back, trade.amount_out, settled_net_gas); - if trade.sandwich.is_some() { - for state in [&mut top, &mut back] { + let mut top = StateResult::new(top, trade.amount_out); + let mut back = StateResult::new(back, trade.amount_out); + // An implausible settled amount outranks a sandwich: if the settled output is not the trade's + // real output, then how MEV moved it is not the story either. + for state in [&mut top, &mut back] { + if compare::implausible_settled_amount(&state.outcome, trade.amount_out) { + state.verdict = Verdict::ImplausibleSettledAmount; + } else if trade.sandwich.is_some() { if let Outcome::Solved(_) = state.outcome { state.verdict = Verdict::Sandwiched; } @@ -327,12 +315,11 @@ pub(crate) fn build_range( solver: trade.solver.clone(), solver_source: trade.solver_source, decoder: trade.decoder, + decode: trade.decode, token_in: trade.token_in, token_out: trade.token_out, amount_in: trade.amount_in, settled_amount_out: trade.amount_out, - settled_amount_out_net_gas: settled_net_gas, - settled_gas: trade.settled_gas, min_amount_out: trade.min_amount_out, declared_quote: trade.declared_quote, quote_timestamp: trade.quote_timestamp, @@ -352,7 +339,6 @@ pub(crate) fn build_range( pub(crate) async fn resolve_block_range( solver: &S, trades: &[DecodedTrade], - prices: &Prices, ) -> anyhow::Result> { let mut tops = Vec::with_capacity(trades.len()); for trade in trades { @@ -376,7 +362,7 @@ pub(crate) async fn resolve_block_range( let back = solver .solve(trade.token_in, trade.token_out, trade.amount_in) .await; - ranges.push(build_range(trade, prices, top, back, &reexecuted)); + ranges.push(build_range(trade, top, back, &reexecuted)); } Ok(ranges) } @@ -386,11 +372,6 @@ mod tests { use alloy::primitives::TxHash; use super::*; - use crate::decoder::Registry; - - fn empty_prices() -> Prices { - Prices::new(&Registry::ethereum()) - } fn trade(settled: u64) -> DecodedTrade { DecodedTrade { @@ -400,15 +381,13 @@ mod tests { venue: "relay".into(), solver: "tycho".into(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender: Address::ZERO, token_in: Address::repeat_byte(0x11), token_out: Address::repeat_byte(0x22), amount_in: U256::from(1_000u64), amount_out: U256::from(settled), - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, @@ -469,7 +448,6 @@ mod tests { fn test_build_range_headline() { let range = build_range( &trade(10_000), - &empty_prices(), solved(10_200, 10_100), solved(10_010, 9_990), &solved(10_010, 9_990), @@ -483,13 +461,12 @@ mod tests { // Fynd fills only 10% of a 10_000 settled trade → reclassified as a coverage miss. let range = build_range( &trade(10_000), - &empty_prices(), solved(1_000, 990), solved(1_000, 990), &solved(1_000, 990), ); assert_eq!(range.verdict, Verdict::CoverageMiss); - assert_eq!(range.top.deltas, Deltas { raw_bps: None, net_bps: None }); + assert_eq!(range.top.deltas, Deltas { raw_bps: None }); assert!(matches!(range.top.outcome, Outcome::Partial(_))); } @@ -504,7 +481,6 @@ mod tests { }); let range = build_range( &sandwiched, - &empty_prices(), solved(10_200, 10_100), solved(9_800, 9_700), &solved(9_800, 9_700), @@ -531,7 +507,6 @@ mod tests { }); let range = build_range( &sandwiched, - &empty_prices(), solved(10_200, 10_100), Outcome::Unsolvable("missing token in Tycho".into()), &Outcome::Unsolvable("re-execution failed".into()), @@ -542,45 +517,6 @@ mod tests { assert_eq!(range.verdict, Verdict::Sandwiched); // headline follows top } - #[test] - fn test_build_range_priced_gas() { - // The settled trader paid 200 token_out units of gas (100 wei at a price of 2 units/wei): - // the secondary net column carries the deduction; the verdict stays gross vs gross. - let mut with_gas = trade(10_000); - with_gas.settled_gas = Some(U256::from(100u64)); - let mut prices = empty_prices(); - prices.insert(with_gas.token_out, 2.0); - - let range = build_range( - &with_gas, - &prices, - solved(10_050, 9_990), - solved(10_050, 9_990), - &solved(10_050, 9_990), - ); - assert_eq!(range.settled_amount_out_net_gas, U256::from(9_800u64)); - assert_eq!(range.settled_amount_out, U256::from(10_000u64)); - assert_eq!(range.verdict, Verdict::Win); - } - - #[test] - fn test_build_range_unpriced_gas() { - // token_out is not in the price map → no deduction. The secondary net column stays - // gross; the verdict is unaffected either way (gross 10_050 beats gross 10_000). - let mut with_gas = trade(10_000); - with_gas.settled_gas = Some(U256::from(100u64)); - - let range = build_range( - &with_gas, - &empty_prices(), - solved(10_050, 9_990), - solved(10_050, 9_990), - &solved(10_050, 9_990), - ); - assert_eq!(range.settled_amount_out_net_gas, U256::from(10_000u64)); - assert_eq!(range.verdict, Verdict::Win); - } - #[tokio::test] async fn resolve_block_range_pairs_top_back_and_reexecution() { // Two trades. The top solve wins; the fresh back solve loses vs settled; the top route @@ -592,7 +528,7 @@ mod tests { reexecuted: solved(9_900, 9_800), }; let trades = [trade(10_000), trade(10_000)]; - let ranges = resolve_block_range(&solver, &trades, &empty_prices()) + let ranges = resolve_block_range(&solver, &trades) .await .unwrap(); @@ -619,7 +555,7 @@ mod tests { reexecuted: solved(10_100, 10_000), }; let trades = [trade(10_000)]; - let ranges = resolve_block_range(&solver, &trades, &empty_prices()) + let ranges = resolve_block_range(&solver, &trades) .await .unwrap(); @@ -634,7 +570,6 @@ mod tests { // back solve is a different route and plays no part in the slippage. let range = build_range( &trade(10_000), - &empty_prices(), solved(10_000, 9_900), solved(10_500, 10_400), &solved(10_050, 9_950), @@ -649,7 +584,6 @@ mod tests { // route still re-executed: the slippage must survive independently of `back`. let range = build_range( &trade(10_000), - &empty_prices(), solved(10_000, 9_900), Outcome::Unsolvable("no route at back-of-block".into()), &solved(10_050, 9_950), @@ -666,7 +600,6 @@ mod tests { // re-execution must still be measured from the raw outcomes. let range = build_range( &trade(10_000), - &empty_prices(), solved(1_000, 990), solved(1_010, 1_000), &solved(1_010, 1_000), diff --git a/tools/hindsight/src/resolve/monitor.rs b/tools/hindsight/src/resolve/monitor.rs index b09463e34..ba75a0752 100644 --- a/tools/hindsight/src/resolve/monitor.rs +++ b/tools/hindsight/src/resolve/monitor.rs @@ -639,7 +639,7 @@ async fn run_session( // Snapshot token prices at top-of-block (N-1) for the headline metric and the top-of-block // USD valuation. let prices_top = snapshot_prices(adapter.solver, decoder.registry()).await; - let ranges = match resolve_block_range(adapter, &trades, &prices_top).await { + let ranges = match resolve_block_range(adapter, &trades).await { Ok(ranges) => ranges, Err(e) => return SessionEnd::Unhealthy(e.to_string()), }; diff --git a/tools/hindsight/src/telemetry.rs b/tools/hindsight/src/telemetry.rs index 5c3150b1f..053625c61 100644 --- a/tools/hindsight/src/telemetry.rs +++ b/tools/hindsight/src/telemetry.rs @@ -30,6 +30,8 @@ const RPC_INDEX_WAIT: &str = "hindsight_rpc_index_wait_seconds"; const SKIPPED_BLOCKS: &str = "hindsight_skipped_blocks_total"; const FEED_REBUILDS: &str = "hindsight_feed_rebuilds_total"; const UNTRACED_TRANSACTIONS: &str = "hindsight_untraced_transactions_total"; +const SEVERAL_LEGS: &str = "hindsight_several_legs_total"; +const VETOED_TRANSACTIONS: &str = "hindsight_vetoed_transactions_total"; /// Absolute USD savings beyond which a comparison is logged with full per-trade context, so large /// outliers can be traced and classified (a genuinely large trade vs a token-mispricing artifact @@ -96,12 +98,14 @@ pub(crate) fn outcome_label(verdict: Verdict) -> &'static str { Verdict::CoverageMiss => "coverage_miss", Verdict::Unsolvable => "unsolvable", Verdict::Sandwiched => "sandwiched", + Verdict::ImplausibleSettledAmount => "implausible_settled_amount", } } /// Register metric descriptions with the active recorder. pub(crate) fn describe() { describe_trade_metrics(); + describe_coverage_metrics(); } /// Register descriptions for the per-trade re-solve metrics: savings, slippage, volume, and the @@ -182,6 +186,12 @@ fn describe_trade_metrics() { including the head check itself. The receipts RPC trails the tycho stream that picks the \ target, so this is a floor on the lag that no amount of solving speed removes" ); +} + +/// Register descriptions for the coverage counters: what the decoder did not record, and why. +/// Every one of these is a trade or block missing from the aggregates, named so the gap is +/// attributable rather than invisible. +fn describe_coverage_metrics() { describe_counter!( SKIPPED_BLOCKS, "Blocks skipped because the RPC could not provide receipts (e.g. it lagged the tycho \ @@ -198,6 +208,18 @@ fn describe_trade_metrics() { still contributes its other trades, so this counts trades missing from the aggregates \ rather than blocks" ); + describe_counter!( + VETOED_TRANSACTIONS, + "Matched transactions dropped by a veto, labelled by the veto that dropped them. A \ + transaction counted here produces no record at all, so this is where coverage the \ + decoder deliberately refuses shows up instead of vanishing" + ); + describe_counter!( + SEVERAL_LEGS, + "Transactions whose declared read was declined because they entered a solver router \ + several times: the legs are separate swaps, so one frame states a fragment of the trade. \ + Netting still runs, so these are not all lost — they are the declared tier giving up" + ); } /// Record a two-state range: the top-of-block (N-1) and back-of-block (N) outcomes, each tagged @@ -317,9 +339,12 @@ fn record_state( .increment(1); } - let sandwiched = state.verdict == Verdict::Sandwiched; + // Neither a sandwich nor an implausible settled amount measures routing quality, so both + // stay out of the savings aggregates while keeping their own labelled trade count. + let excluded_from_savings = + matches!(state.verdict, Verdict::Sandwiched | Verdict::ImplausibleSettledAmount); - if above_floor && !sandwiched { + if above_floor && !excluded_from_savings { if let Some(bps) = state.deltas.raw_bps { histogram!( SAVINGS_BPS, @@ -338,7 +363,9 @@ fn record_state( return None; }; let usd = prices.savings_usd(range.token_out, solved.amount_out, range.settled_amount_out)?; - if sandwiched { + if excluded_from_savings { + // The verdict already records why this delta is not routing quality; the outlier warning + // below is for deltas that claim to be. return Some(usd); } @@ -469,6 +496,16 @@ pub(crate) fn record_untraced_transaction() { counter!(UNTRACED_TRANSACTIONS).increment(1); } +/// Count a transaction dropped by a veto. `veto` is the `Debug` name of the variant, which is a +/// closed set, so it is safe as a label. +pub(crate) fn record_veto(veto: &str) { + counter!(VETOED_TRANSACTIONS, "veto" => veto.to_string()).increment(1); +} + +pub(crate) fn record_several_legs() { + counter!(SEVERAL_LEGS).increment(1); +} + pub(crate) fn record_feed_rebuild() { counter!(FEED_REBUILDS).increment(1); } @@ -568,7 +605,7 @@ mod tests { use super::*; use crate::{ - decoder::{AttributionSource, DecodedTrade, SandwichEvidence}, + decoder::{AttributionSource, DecodeSource, DecodeTier, DecodedTrade, SandwichEvidence}, resolve::{build_range, SolvedAmount}, }; @@ -584,15 +621,13 @@ mod tests { venue: "relay".into(), solver: "tycho".into(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender: Address::ZERO, token_in: Address::repeat_byte(0x11), token_out, amount_in: U256::from(1_000u64), amount_out: U256::from(settled), - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, @@ -642,7 +677,6 @@ mod tests { let usdc = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); let range = build_range( &trade(usdc, 1_000_000_000), - &empty_prices(), solved_by("path_frank_wolfe", 1_010_000_000, 1_005_000_000), solved_by("path_frank_wolfe", 1_010_000_000, 1_005_000_000), &Outcome::Unsolvable("x".into()), @@ -683,7 +717,6 @@ mod tests { // missing sample), so it needs a label value — and it must not be blank. let range = build_range( &trade(Address::repeat_byte(0x22), 1_000), - &empty_prices(), Outcome::Unsolvable("missing token in Tycho".into()), Outcome::Unsolvable("missing token in Tycho".into()), &Outcome::Unsolvable("no top-of-block route to re-execute".into()), @@ -740,7 +773,6 @@ mod tests { // Top wins (net 1005 USDC vs 1000 settled); back loses (net 995). let range = build_range( &trade(usdc, 1_000_000_000), - &empty_prices(), solved(1_010_000_000, 1_005_000_000), solved(998_000_000, 995_000_000), &solved(998_000_000, 995_000_000), @@ -795,7 +827,6 @@ mod tests { // Quoted 1000 USDC at top, re-executed to 1005 USDC at back → +50 bps, +$5 surplus. let range = build_range( &trade(usdc, 1_000_000_000), - &empty_prices(), solved(1_000_000_000, 995_000_000), solved(1_005_000_000, 1_000_000_000), &solved(1_005_000_000, 1_000_000_000), @@ -838,7 +869,6 @@ mod tests { // positive-only USD surplus does not. let range = build_range( &trade(usdc, 1_000_000_000), - &empty_prices(), solved(1_000_000_000, 995_000_000), solved(995_000_000, 990_000_000), &solved(995_000_000, 990_000_000), @@ -879,7 +909,6 @@ mod tests { // The fresh back solve succeeded — slippage must come from the re-execution alone. let range = build_range( &trade(usdc, 1_000_000_000), - &empty_prices(), solved(1_000_000_000, 995_000_000), solved(1_002_000_000, 997_000_000), &Outcome::Unsolvable("re-execution failed: no simulation state".into()), @@ -928,13 +957,8 @@ mod tests { let mut t = trade(address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), 1_000); t.venue = "0xD720183DdA64a8CDb424B5c13aF73baf713521f8".to_string(); t.solver = "0xB6F54cAed61C318027c022c47B94BAF139a99Dab".to_string(); - let range = build_range( - &t, - &empty_prices(), - solved(1_100, 1_050), - solved(1_100, 1_050), - &solved(1_100, 1_050), - ); + let range = + build_range(&t, solved(1_100, 1_050), solved(1_100, 1_050), &solved(1_100, 1_050)); let recorder = PrometheusBuilder::new().build_recorder(); let handle = recorder.handle(); @@ -961,13 +985,8 @@ mod tests { let mut t = trade(address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), 1_000); t.solver = "relay".to_string(); t.solver_source = AttributionSource::Fallback; - let range = build_range( - &t, - &empty_prices(), - solved(1_100, 1_050), - solved(1_100, 1_050), - &solved(1_100, 1_050), - ); + let range = + build_range(&t, solved(1_100, 1_050), solved(1_100, 1_050), &solved(1_100, 1_050)); let recorder = PrometheusBuilder::new().build_recorder(); let handle = recorder.handle(); @@ -996,7 +1015,6 @@ mod tests { t.amount_in = U256::from(1_000_000_000u64); // 1000 USDC let range = build_range( &t, - &empty_prices(), Outcome::Unsolvable("no route".into()), Outcome::Unsolvable("no route".into()), &Outcome::Unsolvable("no top-of-block route to re-execute".into()), @@ -1023,7 +1041,6 @@ mod tests { fn test_record_range_unsolvable() { let range = build_range( &trade(Address::repeat_byte(0x22), 1_000), - &empty_prices(), Outcome::Unsolvable("x".into()), Outcome::Unsolvable("x".into()), &Outcome::Unsolvable("x".into()), @@ -1060,7 +1077,6 @@ mod tests { prices.insert(usdc, 2e-9); let range = build_range( &sandwiched, - &prices, solved(1_100_000_000, 1_090_000_000), solved(1_100_000_000, 1_090_000_000), &solved(1_100_000_000, 1_090_000_000), @@ -1092,7 +1108,6 @@ mod tests { // whose bps and win count would swamp the unweighted metrics if it were recorded. let range = build_range( &trade(usdc, 1_000_000), - &empty_prices(), solved(1_005_000, 1_005_000), solved(1_005_000, 1_005_000), &solved(1_005_000, 1_005_000), @@ -1123,7 +1138,6 @@ mod tests { // its count and (solved) bps still land in the metrics. let range = build_range( &trade(Address::repeat_byte(0x42), 1_000), - &empty_prices(), solved(1_100, 1_050), solved(1_100, 1_050), &solved(1_100, 1_050), diff --git a/tools/hindsight/src/usd.rs b/tools/hindsight/src/usd.rs index 08689d8d9..e77182ca6 100644 --- a/tools/hindsight/src/usd.rs +++ b/tools/hindsight/src/usd.rs @@ -95,24 +95,6 @@ impl Prices { usd.is_finite().then_some(usd) } - /// Convert a native gas cost (wei of the gas token) into `token` native units at the - /// snapshot price. - /// - /// `price[token]` is the token's native-unit amount per wei, so the conversion is one - /// multiplication. Returns `None` when `token` is not priced. The f64 round-trip loses - /// wei-level precision, which is acceptable for a gas deduction — the cost itself is exact but - /// its value in the output token is an estimate by nature. - // Truncation is intentional: whole token units are sufficient for a gas estimate. - // Sign loss is impossible: gas_wei is from a U256 and price_of only returns positive values. - #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - pub(crate) fn gas_in_token(&self, gas_wei: U256, token: Address) -> Option { - let price = self.price_of(token)?; - let units = u256_to_f64(gas_wei) * price; - units - .is_finite() - .then(|| U256::from(units as u128)) - } - /// Signed USD savings of Fynd's output vs the settled amount (positive = Fynd better). /// /// Both amounts are `token_out` native units, valued in USD via `Prices::value_usd`; the @@ -245,21 +227,6 @@ mod tests { assert!((v - 2_000.0).abs() < 1e-3, "expected $2000, got {v}"); } - #[test] - fn test_gas_in_token_at_snapshot_price() { - // 0.001 ETH of gas, USDC at 2e-9 native units per wei (ETH = $2000) → 2 USDC. - let gas_wei = U256::from(10u64).pow(U256::from(15u64)); - let got = prices() - .gas_in_token(gas_wei, USDC) - .unwrap(); - assert_eq!(got, U256::from(2_000_000u64)); - } - - #[test] - fn test_gas_in_token_unpriced() { - assert_eq!(prices().gas_in_token(U256::from(1u64), Address::repeat_byte(0x42)), None); - } - #[test] fn test_value_usd_unpriced_or_no_anchor() { assert_eq!(prices().value_usd(Address::repeat_byte(0x42), U256::from(1u64)), None); diff --git a/tools/hindsight/src/verify/mod.rs b/tools/hindsight/src/verify/mod.rs index f220eb9bd..6849d9108 100644 --- a/tools/hindsight/src/verify/mod.rs +++ b/tools/hindsight/src/verify/mod.rs @@ -405,7 +405,7 @@ mod tests { use alloy::primitives::U256; use super::*; - use crate::decoder::AttributionSource; + use crate::decoder::{AttributionSource, DecodeSource, DecodeTier}; fn addr(n: u8) -> Address { let mut bytes = [0u8; 20]; @@ -421,15 +421,13 @@ mod tests { venue: "relay".to_string(), solver: solver.to_string(), solver_source: AttributionSource::TraceMatch, - decoder: "sender-netting", + decoder: DecodeSource::SenderNetting, + decode: DecodeTier::Netted, sender: addr(1), token_in, token_out, amount_in: U256::from(1000), amount_out: U256::from(2000), - venue_fee_in: None, - venue_fee_out: None, - settled_gas: None, min_amount_out: None, declared_quote: None, quote_timestamp: None,
settled txvenuesolvernet bpssavings
bpssavings