From 9feeabee083147183a52571798cf4b496106904b Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Fri, 14 Aug 2026 15:52:17 -0400 Subject: [PATCH 01/13] feat(hindsight): remove settled-gas bookkeeping The headline verdict and every dashboard number compare gross amounts since July; the net-of-gas figures were secondary record columns with no consumer. Removed end to end: the trader-paid gas derivation, the solver-frame gas isolation from the trace, the settled_gas trade field, the settled_gas_cost / settled_amount_out_net_gas / net_bps record columns, and the gas-to-token price conversion. The report's per-trade and per-group bps switch to the gross delta (raw_bps), which every recorded dataset already carries. Fynd's own quotes stay gas-aware; this removes only the settled-side bookkeeping. Co-Authored-By: Claude Fable 5 --- tools/hindsight/CLAUDE.md | 8 +- tools/hindsight/README.md | 6 +- tools/hindsight/src/decoder/decode.rs | 19 +-- tools/hindsight/src/decoder/intents/cow.rs | 5 +- tools/hindsight/src/decoder/mod.rs | 21 +--- .../hindsight/src/decoder/netting_decoders.rs | 13 +- tools/hindsight/src/decoder/sandwich.rs | 1 - tools/hindsight/src/decoder/trace.rs | 112 ------------------ .../hindsight/src/decoder/venues/metamask.rs | 2 - tools/hindsight/src/decoder/venues/rabby.rs | 2 - tools/hindsight/src/decoder/venues/relay.rs | 29 +---- tools/hindsight/src/report/aggregate.rs | 16 +-- tools/hindsight/src/report/html.rs | 10 +- tools/hindsight/src/report/mod.rs | 4 +- tools/hindsight/src/report/record.rs | 6 +- tools/hindsight/src/resolve/compare.rs | 87 ++++---------- tools/hindsight/src/resolve/jsonl.rs | 16 +-- tools/hindsight/src/resolve/mod.rs | 92 ++------------ tools/hindsight/src/resolve/monitor.rs | 2 +- tools/hindsight/src/telemetry.rs | 30 +---- tools/hindsight/src/usd.rs | 33 ------ tools/hindsight/src/verify/mod.rs | 1 - 22 files changed, 77 insertions(+), 438 deletions(-) diff --git a/tools/hindsight/CLAUDE.md b/tools/hindsight/CLAUDE.md index 12a5abeb9..642f9f6ff 100644 --- a/tools/hindsight/CLAUDE.md +++ b/tools/hindsight/CLAUDE.md @@ -17,7 +17,7 @@ Built-in address books: `ethereum`, `base`, `unichain`, `arbitrum`, `bsc`, `poly 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`, + 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. @@ -152,8 +152,8 @@ across the 165 trades both paths could decode. like-for-like. 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). +- `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). @@ -200,7 +200,7 @@ It surfaces three ways: ## 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 + enough for matching, attribution, 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 — diff --git a/tools/hindsight/README.md b/tools/hindsight/README.md index 36c021d2d..ce801098f 100644 --- a/tools/hindsight/README.md +++ b/tools/hindsight/README.md @@ -59,7 +59,7 @@ All take `--chain` (selects the address book) and `--registry` / ┌─────────────────┐ swap_intent ┌─────────────────┐ │ post-processing │ ───────────────▶ │ SolverKnowledge │ └────────┬────────┘ └─────────────────┘ - │ veto → venue attribution → solver attribution → gas → intent → sandwich scan + │ veto → venue attribution → solver attribution → intent → sandwich scan ▼ DecodedTrade ``` @@ -123,8 +123,8 @@ match role { (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) + 0x called directly → Sender → [ SenderNetting ] (your own transaction) + 0x settling your intent order → Intent → the intent decoders (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 diff --git a/tools/hindsight/src/decoder/decode.rs b/tools/hindsight/src/decoder/decode.rs index cd0aeb82e..67363c196 100644 --- a/tools/hindsight/src/decoder/decode.rs +++ b/tools/hindsight/src/decoder/decode.rs @@ -140,22 +140,8 @@ pub(crate) struct DecodeContext<'a, P> { 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). +/// comparable (venue fees backed out). pub(crate) struct TraderFlow { /// The address whose net flow the swap was read from. pub tracked: Address, @@ -167,8 +153,6 @@ pub(crate) struct TraderFlow { /// 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 { @@ -179,7 +163,6 @@ impl TraderFlow { venue_fee_in: None, venue_fee_out: None, solver_override: None, - gas_scope: GasScope::NotCharged, } } diff --git a/tools/hindsight/src/decoder/intents/cow.rs b/tools/hindsight/src/decoder/intents/cow.rs index 3aa494107..83aee1efa 100644 --- a/tools/hindsight/src/decoder/intents/cow.rs +++ b/tools/hindsight/src/decoder/intents/cow.rs @@ -20,7 +20,7 @@ use alloy::{ use async_trait::async_trait; use crate::decoder::{ - decode::{DecodeContext, GasScope, TradeDecoder, TraderFlow}, + decode::{DecodeContext, TradeDecoder, TraderFlow}, transfer_ledger::{to_primitive_log, NetSwap}, }; @@ -118,8 +118,6 @@ impl TradeDecoder

for CowSettlement { 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, }) } } @@ -214,7 +212,6 @@ mod tests { 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] diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index c3b30fad4..5f8acae62 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -44,10 +44,10 @@ use futures::stream::StreamExt; use tracing::{debug, warn}; use crate::decoder::{ - decode::{recover, DecodeContext, GasScope, TraderFlow}, + decode::{recover, DecodeContext, TraderFlow}, matching::MatchedSolverTrade, solvers::SwapIntent, - trace::{collect_native_transfers, fetch_trace, route_gas}, + trace::{collect_native_transfers, fetch_trace}, transfer_ledger::TransferLedger, }; pub(crate) use crate::decoder::{ @@ -92,13 +92,6 @@ pub(crate) struct DecodedTrade { /// `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 @@ -352,15 +345,6 @@ impl Decoder

{ registry, ); - // 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, - } - .map(|units| units * U256::from(receipt.effective_gas_price)); - // The trader's swap terms, when the settling solver frame's own calldata declares them. // 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 @@ -399,7 +383,6 @@ impl Decoder

{ amount_out: flow.swap.amount_out, venue_fee_in: flow.venue_fee_in, venue_fee_out: flow.venue_fee_out, - settled_gas, min_amount_out, declared_quote, quote_timestamp, diff --git a/tools/hindsight/src/decoder/netting_decoders.rs b/tools/hindsight/src/decoder/netting_decoders.rs index d2f5f76bb..2ae7a92ce 100644 --- a/tools/hindsight/src/decoder/netting_decoders.rs +++ b/tools/hindsight/src/decoder/netting_decoders.rs @@ -18,7 +18,7 @@ use alloy::{primitives::Address, providers::Provider}; use async_trait::async_trait; use crate::decoder::{ - decode::{DecodeContext, GasScope, TradeDecoder, TraderFlow}, + decode::{DecodeContext, TradeDecoder, TraderFlow}, transfer_ledger::{NetSwap, TransferLedger}, }; @@ -35,10 +35,7 @@ pub(crate) fn sender_flow( ) -> Option { transfer_ledger .net_swap(sender) - .map(|swap| TraderFlow { - gas_scope: GasScope::WholeTransaction, - ..TraderFlow::without_fees(sender, swap) - }) + .map(|swap| TraderFlow::without_fees(sender, swap)) .or_else(|| { transfer_ledger .net_swap(entry_point) @@ -62,10 +59,7 @@ pub(crate) fn venue_flow( 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; - } + let flow = sender_flow(transfer_ledger, sender, entry_point)?; if fee_collectors.contains(&flow.tracked) { return Some(flow); } @@ -102,7 +96,6 @@ fn back_out_venue_fees( venue_fee_in, venue_fee_out, solver_override: flow.solver_override, - gas_scope: flow.gas_scope, } } diff --git a/tools/hindsight/src/decoder/sandwich.rs b/tools/hindsight/src/decoder/sandwich.rs index 57e4cdfbb..c0c6524c2 100644 --- a/tools/hindsight/src/decoder/sandwich.rs +++ b/tools/hindsight/src/decoder/sandwich.rs @@ -283,7 +283,6 @@ mod tests { 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/trace.rs b/tools/hindsight/src/decoder/trace.rs index 5eb2f7654..d7e50ac93 100644 --- a/tools/hindsight/src/decoder/trace.rs +++ b/tools/hindsight/src/decoder/trace.rs @@ -57,28 +57,6 @@ 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 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. /// @@ -179,96 +157,6 @@ 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 - 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))); - } - - #[test] - fn test_route_gas_unknown_venue() { - // Unknown venue: no registry match, so the most gas-consuming child is the route. - let registry = Registry::ethereum(); - let client = 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), - ]; - - assert_eq!(route_gas(&root, ®istry), Some(U256::from(400_000u64))); - } - - #[test] - fn test_route_gas_reverted_and_empty() { - let registry = Registry::ethereum(); - let client = addr(2); - - 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 leaf = frame("CALL", addr(1), client, 0); - assert_eq!(route_gas(&leaf, ®istry), None); - } - - #[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))); - } - - #[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))); - } - #[test] fn test_find_solver_frame_reverted_frames() { let registry = Registry::ethereum(); diff --git a/tools/hindsight/src/decoder/venues/metamask.rs b/tools/hindsight/src/decoder/venues/metamask.rs index eae4787f5..524f381a4 100644 --- a/tools/hindsight/src/decoder/venues/metamask.rs +++ b/tools/hindsight/src/decoder/venues/metamask.rs @@ -71,7 +71,6 @@ mod tests { use super::*; use crate::decoder::{ - decode::GasScope, registry::Registry, test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, transfer_ledger::TransferLedger, @@ -183,7 +182,6 @@ mod tests { 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] diff --git a/tools/hindsight/src/decoder/venues/rabby.rs b/tools/hindsight/src/decoder/venues/rabby.rs index 27a1bd396..b3215e591 100644 --- a/tools/hindsight/src/decoder/venues/rabby.rs +++ b/tools/hindsight/src/decoder/venues/rabby.rs @@ -67,7 +67,6 @@ mod tests { use super::*; use crate::decoder::{ - decode::GasScope, registry::Registry, test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, transfer_ledger::TransferLedger, @@ -137,7 +136,6 @@ mod tests { 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] diff --git a/tools/hindsight/src/decoder/venues/relay.rs b/tools/hindsight/src/decoder/venues/relay.rs index 6d966beb1..876a59d02 100644 --- a/tools/hindsight/src/decoder/venues/relay.rs +++ b/tools/hindsight/src/decoder/venues/relay.rs @@ -18,7 +18,7 @@ use alloy::{ use async_trait::async_trait; use crate::decoder::{ - decode::{DecodeContext, GasScope, TradeDecoder, TraderFlow}, + decode::{DecodeContext, TradeDecoder, TraderFlow}, netting_decoders::venue_flow, solvers, trace, transfer_ledger::{NetSwap, TransferLedger}, @@ -82,21 +82,8 @@ impl TradeDecoder

for RelayCalldata { .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, + tracked: ctx.receipt.from, swap: NetSwap { token_in: intent.token_in, amount_in: intent.amount_in, @@ -106,7 +93,6 @@ impl TradeDecoder

for RelayCalldata { venue_fee_in, venue_fee_out, solver_override: None, - gas_scope, }) } } @@ -224,7 +210,6 @@ mod tests { use super::*; use crate::decoder::{ - decode::GasScope, registry::Registry, test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, }; @@ -397,7 +382,6 @@ mod tests { 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] @@ -446,7 +430,6 @@ mod tests { 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 { @@ -527,7 +510,6 @@ mod tests { 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] @@ -603,9 +585,9 @@ mod tests { } #[tokio::test] - async fn test_decode_collector_funded_is_not_charged_gas() { + async fn test_decode_collector_funded_rebalance() { // The fee collector, not the sender, net-sends the input token: a solver-initiated - // rebalance, which charges no gas to any trader. + // rebalance still decodes from the calldata, with the intent's own amounts. let registry = Registry::ethereum(); let sender = addr(1); let collector = relay_collector(®istry); @@ -617,7 +599,8 @@ mod tests { let flow = decode_calldata(®istry, &root, &ledger, sender, ROUTER) .await .unwrap(); - assert_eq!(flow.gas_scope, GasScope::NotCharged); + assert_eq!(flow.tracked, sender); + assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); } #[tokio::test] diff --git a/tools/hindsight/src/report/aggregate.rs b/tools/hindsight/src/report/aggregate.rs index 793c0ba1e..1bcd19907 100644 --- a/tools/hindsight/src/report/aggregate.rs +++ b/tools/hindsight/src/report/aggregate.rs @@ -69,7 +69,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, } @@ -78,7 +78,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, } @@ -143,7 +143,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(), @@ -171,10 +171,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()) - .filter_map(|r| r.top.net_bps) + .filter_map(|r| r.top.raw_bps) .collect(); GroupStats { name: name.clone(), @@ -191,7 +191,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()) @@ -239,7 +239,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, }) }) @@ -306,7 +306,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, }, diff --git a/tools/hindsight/src/report/html.rs b/tools/hindsight/src/report/html.rs index ec69abc85..f3d34a50b 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..337523acc 100644 --- a/tools/hindsight/src/report/mod.rs +++ b/tools/hindsight/src/report/mod.rs @@ -144,7 +144,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,7 +182,7 @@ 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() } diff --git a/tools/hindsight/src/report/record.rs b/tools/hindsight/src/report/record.rs index 2cb233ab0..cb0e81d3b 100644 --- a/tools/hindsight/src/report/record.rs +++ b/tools/hindsight/src/report/record.rs @@ -26,7 +26,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)] @@ -92,7 +92,6 @@ mod tests { 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 +108,6 @@ mod tests { }); let range = build_range( &trade, - &prices, top, Outcome::Unsolvable("x".into()), &Outcome::Unsolvable("x".into()), @@ -125,7 +123,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 98deccae7..b26e4ff8a 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 @@ -104,22 +99,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)), } } @@ -129,7 +115,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; @@ -157,82 +143,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] @@ -240,8 +197,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..14f940c49 100644 --- a/tools/hindsight/src/resolve/jsonl.rs +++ b/tools/hindsight/src/resolve/jsonl.rs @@ -172,8 +172,6 @@ fn comparison_record( "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 +207,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()), @@ -342,7 +339,6 @@ mod tests { 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 +346,6 @@ mod tests { }; let range = build_range( &trade, - &empty_prices(), Outcome::Unsolvable("x".into()), Outcome::Unsolvable("x".into()), &Outcome::Unsolvable("x".into()), @@ -420,7 +415,6 @@ mod tests { 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 +450,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 +485,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() > @@ -544,7 +538,6 @@ mod tests { 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()), @@ -603,7 +595,6 @@ mod tests { 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 +618,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 eaace080b..464a897ce 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, 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 } } @@ -236,12 +233,6 @@ pub(crate) struct RangeComparison { 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 +275,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,20 +283,15 @@ 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); + let mut top = StateResult::new(top, trade.amount_out); + let mut back = StateResult::new(back, trade.amount_out); if trade.sandwich.is_some() { for state in [&mut top, &mut back] { if let Outcome::Solved(_) = state.outcome { @@ -331,8 +312,6 @@ pub(crate) fn build_range( 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 +331,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 +354,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 +364,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 { @@ -408,7 +381,6 @@ mod tests { 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 +441,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 +454,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 +474,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 +500,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 +510,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 +521,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 +548,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 +563,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 +577,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 +593,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 1822ea3f9..d9444e3aa 100644 --- a/tools/hindsight/src/telemetry.rs +++ b/tools/hindsight/src/telemetry.rs @@ -598,7 +598,6 @@ mod tests { 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, @@ -648,7 +647,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()), @@ -689,7 +687,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()), @@ -746,7 +743,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), @@ -801,7 +797,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), @@ -844,7 +839,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), @@ -885,7 +879,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()), @@ -934,13 +927,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(); @@ -967,13 +955,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(); @@ -1002,7 +985,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()), @@ -1029,7 +1011,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()), @@ -1066,7 +1047,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), @@ -1098,7 +1078,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), @@ -1129,7 +1108,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..9c45a98d0 100644 --- a/tools/hindsight/src/verify/mod.rs +++ b/tools/hindsight/src/verify/mod.rs @@ -429,7 +429,6 @@ mod tests { 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, From 6b08ddb368e5531d1f00c9d75138bb7c9c89f3b2 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Aug 2026 16:22:21 -0400 Subject: [PATCH 02/13] feat(hindsight): trace whole blocks in one call One debug_traceBlockByNumber call replaces the per-transaction debug_traceTransaction wave. A transaction the tracer cannot process still costs only that trade; a failure of the whole call is the block's error. --- tools/hindsight/src/decoder/mod.rs | 70 +++++++++++++--------------- tools/hindsight/src/decoder/trace.rs | 51 ++++++++++++++------ 2 files changed, 69 insertions(+), 52 deletions(-) diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index 5f8acae62..21b855aad 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -40,14 +40,13 @@ use alloy::{ rpc::types::trace::geth::CallFrame, }; use anyhow::Context; -use futures::stream::StreamExt; use tracing::{debug, warn}; use crate::decoder::{ decode::{recover, DecodeContext, TraderFlow}, matching::MatchedSolverTrade, solvers::SwapIntent, - trace::{collect_native_transfers, fetch_trace}, + trace::{collect_native_transfers, fetch_block_traces}, transfer_ledger::TransferLedger, }; pub(crate) use crate::decoder::{ @@ -154,11 +153,6 @@ fn intent_fields(intent: Option<&SwapIntent>) -> (Option, Option, Op (min_amount_out, declared_quote, quote_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

{ @@ -226,41 +220,29 @@ impl Decoder

{ }) .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 - .iter() - .map(|(_, m)| fetch_trace(&self.provider, m.receipt.transaction_hash)), - ) - .buffered(TRACE_CONCURRENCY) - .collect::>() - .await; + // Nothing matched: the block has no solver trades, so its trace is never needed. + if matched.is_empty() { + return Ok(Vec::new()); + } + + // One debug_traceBlockByNumber call covers every matched transaction. 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::with_capacity(matched.len()); - for ((index, matched), trace) in matched.into_iter().zip(traces) { + for (index, matched) in matched { let tx_index = matched .receipt .transaction_index .unwrap_or(index as u64); - let root = match trace { - Ok(root) => root, - Err(e) => { - warn!( - block = block_number, - tx = %matched.receipt.transaction_hash, - "skipping untraceable transaction: {e}" - ); - crate::telemetry::record_untraced_transaction(); - continue; - } + let Some(root) = roots.remove(&matched.receipt.transaction_hash) else { + warn!( + block = block_number, + tx = %matched.receipt.transaction_hash, + "skipping transaction absent from the block trace" + ); + crate::telemetry::record_untraced_transaction(); + continue; }; if let Some(mut trade) = self .decode_transaction(matched, &root, block_number, tx_index) @@ -423,13 +405,25 @@ 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), diff --git a/tools/hindsight/src/decoder/trace.rs b/tools/hindsight/src/decoder/trace.rs index d7e50ac93..573d671de 100644 --- a/tools/hindsight/src/decoder/trace.rs +++ b/tools/hindsight/src/decoder/trace.rs @@ -1,32 +1,55 @@ +use std::collections::HashMap; + 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. From a8bb17c1c88828c3bc78aeecd58788f9c0e29b19 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Aug 2026 16:41:46 -0400 Subject: [PATCH 03/13] refactor(hindsight): bind one SolverDecoder per registry solver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SolverKnowledge becomes SolverDecoder — one trait per solver for its calldata reads, veto, and integrator tag. The implementation is joined onto the registry's solver entry when the address book loads; trade-time lookups go through Registry::solver by address. The per-method name-dispatch functions are deleted. --- tools/hindsight/src/decoder/mod.rs | 7 +- tools/hindsight/src/decoder/registry.rs | 42 ++++-- tools/hindsight/src/decoder/solvers/fly.rs | 22 +-- .../src/decoder/solvers/kyberswap.rs | 36 ++--- tools/hindsight/src/decoder/solvers/lifi.rs | 10 +- tools/hindsight/src/decoder/solvers/mod.rs | 131 ++++++++---------- .../hindsight/src/decoder/solvers/paraswap.rs | 24 ++-- tools/hindsight/src/decoder/solvers/zeroex.rs | 30 ++-- tools/hindsight/src/decoder/venues/relay.rs | 10 +- 9 files changed, 167 insertions(+), 145 deletions(-) diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index 21b855aad..7fee6b6dd 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -308,7 +308,7 @@ impl Decoder

{ // `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 integrator = solvers::integrator(logs, registry); let app_data = intents::venue_tag(registry, entry_point, &root.input); let venue = venue_attribution::attribute( registry, @@ -336,7 +336,10 @@ impl Decoder

{ // dropped (quotes are self-reported); the ABI-decoded terms stay either way. let intent = trace::find_solver_frame(root, registry) .and_then(|frame| { - solvers::swap_intent(&attribution.solver, &frame.input, Some(flow.swap.amount_in)) + let solver = registry.solver(frame.to?)?; + solver + .decoder + .declared_swap(&frame.input, Some(flow.swap.amount_in)) }) .map(|mut intent| { if let Some(quoted) = intent.declared_quote() { diff --git a/tools/hindsight/src/decoder/registry.rs b/tools/hindsight/src/decoder/registry.rs index 8974ba852..c3e2fadc5 100644 --- a/tools/hindsight/src/decoder/registry.rs +++ b/tools/hindsight/src/decoder/registry.rs @@ -115,11 +115,28 @@ impl VenueAddresses { } } +/// 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. @@ -214,6 +231,14 @@ 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() @@ -230,7 +255,7 @@ impl Registry { Ok(Self { solver_names, - solvers: book.solvers, + solvers, names, batch_settlers: book.batch_settlers, labels: book.labels, @@ -258,11 +283,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 @@ -433,7 +457,7 @@ mod tests { registry .solvers .values() - .any(|name| name == "tycho"), + .any(|solver| solver.name == "tycho"), "{chain} has no tycho router" ); } diff --git a/tools/hindsight/src/decoder/solvers/fly.rs b/tools/hindsight/src/decoder/solvers/fly.rs index 9512b7a01..4cb70225f 100644 --- a/tools/hindsight/src/decoder/solvers/fly.rs +++ b/tools/hindsight/src/decoder/solvers/fly.rs @@ -11,7 +11,7 @@ use alloy::primitives::{Address, U256}; -use crate::decoder::solvers::{SolverKnowledge, SwapIntent}; +use crate::decoder::solvers::{SolverDecoder, SwapIntent}; /// Selectors sharing `LibRouter`'s packed layout (`swapWithBackendSignature`, /// `swapWithMagpieSignature`, `swapWithUserSignature`, `swapWithoutSignature`, `swap`). @@ -77,14 +77,14 @@ fn parse(input: &[u8]) -> Option { /// 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 { + fn declared_swap(&self, input: &[u8], _amount_in_hint: Option) -> Option { let data = parse(input)?; if data.amount_in.is_zero() || data.amount_out_min.is_zero() { return None; @@ -125,9 +125,9 @@ mod tests { } #[test] - fn test_real_fixture_swap_intent() { + fn test_real_fixture_declared_swap() { let intent = Fly - .swap_intent(&real_input(), None) + .declared_swap(&real_input(), None) .unwrap(); assert_eq!(intent.token_in, address!("0xfde4c96c8593536e31f229ea8f37b2ada2699bb2")); assert_eq!(intent.token_out, Address::ZERO); @@ -164,7 +164,7 @@ mod tests { fn test_wrong_selector() { let mut input = real_input(); input[0] = 0xff; - assert!(Fly.swap_intent(&input, None).is_none()); + assert!(Fly.declared_swap(&input, None).is_none()); } #[test] @@ -172,17 +172,17 @@ mod tests { let full = real_input(); // Cut before the fixed-offset fields are readable at all. assert!(Fly - .swap_intent(&full[..100], None) + .declared_swap(&full[..100], None) .is_none()); // Cut inside the packed-header pointer's target word. assert!(Fly - .swap_intent(&full[..300], None) + .declared_swap(&full[..300], None) .is_none()); } #[test] fn test_empty_input() { - assert!(Fly.swap_intent(&[], None).is_none()); + assert!(Fly.declared_swap(&[], None).is_none()); } #[test] @@ -190,7 +190,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!(Fly.declared_swap(&input, None).is_none()); } #[test] @@ -203,6 +203,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!(Fly.declared_swap(&input, None).is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/kyberswap.rs b/tools/hindsight/src/decoder/solvers/kyberswap.rs index 34347c28e..6bf80690f 100644 --- a/tools/hindsight/src/decoder/solvers/kyberswap.rs +++ b/tools/hindsight/src/decoder/solvers/kyberswap.rs @@ -12,7 +12,7 @@ use alloy::{ sol_types::SolCall, }; -use crate::decoder::solvers::{SolverKnowledge, SwapIntent}; +use crate::decoder::solvers::{SolverDecoder, SwapIntent}; /// `KyberSwap` represents native ETH with this sentinel address rather than the zero address — /// hindsight's convention — so it is normalized on the way out. @@ -90,7 +90,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 @@ -98,7 +98,7 @@ 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 { + fn declared_swap(&self, input: &[u8], _amount_in_hint: Option) -> Option { let call = swapCall::abi_decode(input).ok()?; let desc = call.execution.desc; if desc.amount.is_zero() || desc.minReturnAmount.is_zero() { @@ -198,11 +198,11 @@ mod tests { } #[test] - fn test_swap_intent_round_trip() { + fn test_declared_swap_round_trip() { let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); let intent = Kyberswap - .swap_intent(&swap_calldata(src, dst, 1_000_000, 990_000, ""), None) + .declared_swap(&swap_calldata(src, dst, 1_000_000, 990_000, ""), None) .unwrap(); assert_eq!(intent.token_in, src); assert_eq!(intent.token_out, dst); @@ -231,11 +231,11 @@ mod tests { } #[test] - fn test_swap_intent_with_declared_quote() { + fn test_declared_swap_with_declared_quote() { let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); let intent = Kyberswap - .swap_intent(&swap_calldata(src, dst, 1_000_000, 990_000, BLOB), None) + .declared_swap(&swap_calldata(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)); @@ -243,13 +243,13 @@ mod tests { } #[test] - fn test_swap_intent_malformed_quote_does_not_fail_the_intent() { + fn test_declared_swap_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( + .declared_swap( &swap_calldata(src, dst, 1_000_000, 990_000, "{\"Source\":\"relay\"}"), None, ) @@ -259,9 +259,9 @@ mod tests { } #[test] - fn test_swap_intent_normalizes_native_eth() { + fn test_declared_swap_normalizes_native_eth() { let intent = Kyberswap - .swap_intent( + .declared_swap( &swap_calldata(KYBERSWAP_NATIVE, Address::repeat_byte(0x22), 1_000, 900, ""), None, ) @@ -271,29 +271,29 @@ mod tests { } #[test] - fn test_swap_intent_zero_amounts_rejected() { + fn test_declared_swap_zero_amounts_rejected() { let a = Address::repeat_byte(0x11); let b = Address::repeat_byte(0x22); assert!(Kyberswap - .swap_intent(&swap_calldata(a, b, 0, 900, ""), None) + .declared_swap(&swap_calldata(a, b, 0, 900, ""), None) .is_none()); assert!(Kyberswap - .swap_intent(&swap_calldata(a, b, 1_000, 0, ""), None) + .declared_swap(&swap_calldata(a, b, 1_000, 0, ""), None) .is_none()); } #[test] - fn test_swap_intent_garbage_input() { + fn test_declared_swap_garbage_input() { assert!(Kyberswap - .swap_intent(&[], None) + .declared_swap(&[], None) .is_none()); assert!(Kyberswap - .swap_intent(&[0xde, 0xad, 0xbe, 0xef], None) + .declared_swap(&[0xde, 0xad, 0xbe, 0xef], None) .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) + .declared_swap(&calldata_with(BLOB), None) .is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/lifi.rs b/tools/hindsight/src/decoder/solvers/lifi.rs index f5d0ead81..965717dcb 100644 --- a/tools/hindsight/src/decoder/solvers/lifi.rs +++ b/tools/hindsight/src/decoder/solvers/lifi.rs @@ -5,18 +5,18 @@ use alloy::{rpc::types::Log, sol, sol_types::SolEvent}; -use crate::decoder::{solvers::SolverKnowledge, transfer_ledger::to_primitive_log, veto::Veto}; +use crate::decoder::{solvers::SolverDecoder, transfer_ledger::to_primitive_log, veto::Veto}; /// The `LiFi` solver. pub(crate) struct Lifi; -impl SolverKnowledge for Lifi { +impl SolverDecoder for Lifi { /// Veto transactions that 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 solver_veto(&self, logs: &[Log]) -> Option { + fn veto(&self, logs: &[Log]) -> Option { logs.iter() .any(|log| log.topics().first() == Some(&LiFiTransferStarted::SIGNATURE_HASH)) .then_some(Veto::BridgeOrder) @@ -93,10 +93,10 @@ mod tests { Bytes::default(), ); let logs = vec![Log { inner: primitive, ..Default::default() }]; - assert_eq!(Lifi.solver_veto(&logs), Some(Veto::BridgeOrder)); + assert_eq!(Lifi.veto(&logs), Some(Veto::BridgeOrder)); 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_eq!(Lifi.veto(&swap_logs), None); } #[test] diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index a7cbe80b6..ede84f6d9 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -1,10 +1,12 @@ -//! 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. +//! 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 swap intent recovered from calldata, +//! or a matching 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 attribution; pub(crate) mod fly; @@ -106,52 +108,51 @@ impl SwapIntent { } } -/// Solver-specific knowledge beyond the address-book entry. +/// One solver's decoder: everything the solver's own calldata and logs can say about a trade. /// -/// 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 { +/// Every method has a default meaning "this solver's data does not carry that", so a solver only +/// implements what its transactions expose; most solvers need no code at all. +pub(crate) trait SolverDecoder: 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. + /// carries them plainly enough to recover without netting a settled amount. Called with + /// the solver frame's input (found via `trace::find_solver_frame`), 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 { + /// `amount_in_hint` is a netted input amount, when one is known. Some extractors (`ParaSwap`) + /// need it to locate fields by value rather than by ABI offset. + fn declared_swap(&self, _input: &[u8], _amount_in_hint: Option) -> Option { None } /// 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. + /// plainly enough to recover — whose receipt the settled amount is read from, since calldata + /// alone never carries a settled amount. Called with the same solver-frame input as + /// `declared_swap`. `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 } /// 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 { + /// transaction is decoded. + fn veto(&self, _logs: &[Log]) -> Option { 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`). + /// its swap event; venue attribution maps that tag to a venue. fn integrator(&self, _logs: &[Log]) -> Option { None } } -/// 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)] = &[ ("fly", &fly::Fly), ("kyberswap", &kyberswap::Kyberswap), ("lifi", &lifi::Lifi), @@ -159,6 +160,21 @@ const IMPLEMENTATIONS: &[(&str, &'static dyn SolverKnowledge)] = &[ ("0x", &zeroex::ZeroEx), ]; +/// 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; + +impl SolverDecoder for NoDecoder {} + +/// 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) + .map_or(&NoDecoder, |(_, decoder)| *decoder) +} + /// The veto a solver places on a matched transaction that must be skipped instead of decoded, /// if any. /// @@ -167,48 +183,19 @@ const IMPLEMENTATIONS: &[(&str, &'static dyn SolverKnowledge)] = &[ /// 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 + std::iter::once(entry_point) + .chain(logs.iter().map(Log::address)) + .filter_map(|address| registry.solver(address)) + .find_map(|solver| solver.decoder.veto(logs)) } -/// 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) -} - -/// 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) +/// The order-flow integrator tag declared in a transaction's logs, from whichever log-emitting +/// 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], registry: &Registry) -> Option { + logs.iter() + .filter_map(|log| registry.solver(log.address())) + .find_map(|solver| solver.decoder.integrator(logs)) } /// Whether a declared quote is in the same units as the settled output. @@ -308,7 +295,11 @@ mod tests { ] { 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()); + assert!(decoder_for("paraswap") + .declared_swap(&input, Some(amount_in)) + .is_some()); + assert!(decoder_for("1inch") + .declared_swap(&input, Some(amount_in)) + .is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/paraswap.rs b/tools/hindsight/src/decoder/solvers/paraswap.rs index 48dff8cbc..1ee4546e6 100644 --- a/tools/hindsight/src/decoder/solvers/paraswap.rs +++ b/tools/hindsight/src/decoder/solvers/paraswap.rs @@ -11,7 +11,7 @@ use alloy::primitives::{Address, U256}; -use crate::decoder::solvers::{SolverKnowledge, SwapIntent}; +use crate::decoder::solvers::{SolverDecoder, SwapIntent}; /// Byte length of an ABI-encoded word. const WORD_LEN: usize = 32; @@ -38,7 +38,7 @@ fn address_from_word(word: U256) -> Address { /// The `ParaSwap` solver. pub(crate) struct Paraswap; -impl SolverKnowledge for Paraswap { +impl SolverDecoder 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. @@ -51,7 +51,7 @@ impl SolverKnowledge for Paraswap { /// 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 { + fn declared_swap(&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; @@ -119,7 +119,7 @@ mod tests { U256::ZERO, // metadata ]; let intent = Paraswap - .swap_intent(&calldata(&words), Some(amount_in)) + .declared_swap(&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)); @@ -139,13 +139,13 @@ mod tests { U256::from(171_602_266u64), ]; assert!(Paraswap - .swap_intent(&calldata(&words), Some(U256::from(999u64))) + .declared_swap(&calldata(&words), Some(U256::from(999u64))) .is_none()); assert!(Paraswap - .swap_intent(&[], Some(U256::from(1u64))) + .declared_swap(&[], Some(U256::from(1u64))) .is_none()); assert!(Paraswap - .swap_intent(&calldata(&words), Some(U256::ZERO)) + .declared_swap(&calldata(&words), Some(U256::ZERO)) .is_none()); } @@ -161,7 +161,7 @@ mod tests { U256::from(171_602_266u64), ]; assert!(Paraswap - .swap_intent(&calldata(&words), None) + .declared_swap(&calldata(&words), None) .is_none()); } @@ -178,7 +178,7 @@ mod tests { U256::from(400_000u64), ]; assert!(Paraswap - .swap_intent(&calldata(&below), Some(amount_in)) + .declared_swap(&calldata(&below), Some(amount_in)) .is_none()); let far_above = [ U256::from(0x1111u64), @@ -188,7 +188,7 @@ mod tests { U256::from(10_000_000u64), ]; assert!(Paraswap - .swap_intent(&calldata(&far_above), Some(amount_in)) + .declared_swap(&calldata(&far_above), Some(amount_in)) .is_none()); } @@ -206,7 +206,7 @@ mod tests { U256::from(995_000u64), ]; assert!(Paraswap - .swap_intent(&calldata(&words), Some(amount_in)) + .declared_swap(&calldata(&words), Some(amount_in)) .is_none()); } @@ -217,7 +217,7 @@ mod tests { 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)) + .declared_swap(&calldata(&words), Some(amount_in)) .is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/zeroex.rs b/tools/hindsight/src/decoder/solvers/zeroex.rs index 4a9e7c434..4a589aae6 100644 --- a/tools/hindsight/src/decoder/solvers/zeroex.rs +++ b/tools/hindsight/src/decoder/solvers/zeroex.rs @@ -13,7 +13,7 @@ //! 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. @@ -24,7 +24,7 @@ use alloy::{ sol_types::SolCall, }; -use crate::decoder::solvers::{SolverKnowledge, SwapIntent}; +use crate::decoder::solvers::{SolverDecoder, SwapIntent}; sol! { /// `IAllowanceHolder.exec` — Relay's 0x flow always enters through this wrapper before @@ -100,7 +100,7 @@ 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 @@ -109,7 +109,7 @@ impl SolverKnowledge for ZeroEx { /// 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 { + fn declared_swap(&self, input: &[u8], _amount_in_hint: Option) -> Option { let call = execCall::abi_decode(input).ok()?; if call.amount.is_zero() { return None; @@ -129,7 +129,7 @@ impl SolverKnowledge for ZeroEx { /// 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 + /// and bare (`execute` called directly): unlike `declared_swap`, 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) { @@ -167,9 +167,9 @@ mod tests { const RELAY_ROUTER: Address = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); #[test] - fn test_real_settled_swap_intent() { + fn test_real_settled_declared_swap() { let intent = ZeroEx - .swap_intent(&settled_input(), None) + .declared_swap(&settled_input(), None) .unwrap(); assert_eq!(intent.token_in, Address::ZERO); // 0x's native-ETH sentinel, normalized assert_eq!(intent.token_out, USDC); @@ -184,11 +184,11 @@ mod tests { } #[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) + .declared_swap(&reverted_input(), None) .unwrap(); assert_eq!(intent.token_in, Address::ZERO); assert_eq!(intent.token_out, USDC); @@ -210,8 +210,8 @@ 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 + fn test_bare_settler_entry_has_no_declared_swap_but_resolves_recipient() { + // A direct `execute` call (no `AllowanceHolder` wrapper): declared_swap has nowhere to read // token_in/amount_in from, so it declines; output_recipient does not need them. let call = executeCall { slippage: AllowedSlippage { @@ -224,7 +224,7 @@ mod tests { }; let input = executeCall::abi_encode(&call); assert!(ZeroEx - .swap_intent(&input, None) + .declared_swap(&input, None) .is_none()); assert_eq!(ZeroEx.output_recipient(&input), Some(RELAY_ROUTER)); } @@ -232,7 +232,7 @@ mod tests { #[test] fn test_garbage_input_declines() { assert!(ZeroEx - .swap_intent(&[0xde, 0xad, 0xbe, 0xef], None) + .declared_swap(&[0xde, 0xad, 0xbe, 0xef], None) .is_none()); assert!(ZeroEx .output_recipient(&[0xde, 0xad, 0xbe, 0xef]) @@ -251,7 +251,7 @@ mod tests { }; let input = execCall::abi_encode(&call); assert!(ZeroEx - .swap_intent(&input, None) + .declared_swap(&input, None) .is_none()); } @@ -277,7 +277,7 @@ mod tests { }; let input = execCall::abi_encode(&call); let intent = ZeroEx - .swap_intent(&input, None) + .declared_swap(&input, None) .unwrap(); assert_eq!(intent.min_amount_out, U256::ZERO); } diff --git a/tools/hindsight/src/decoder/venues/relay.rs b/tools/hindsight/src/decoder/venues/relay.rs index 876a59d02..0f2a46ab8 100644 --- a/tools/hindsight/src/decoder/venues/relay.rs +++ b/tools/hindsight/src/decoder/venues/relay.rs @@ -51,9 +51,13 @@ impl TradeDecoder

for RelayCalldata { 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 solver = ctx.registry.solver(solver_frame.to?)?; + let intent = solver + .decoder + .declared_swap(&solver_frame.input, None)?; + let recipient = solver + .decoder + .output_recipient(&solver_frame.input)?; let amount_out = ctx .transfer_ledger From 3ade7fc4b82d053e0f5629fed9b46d452fba31f2 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Aug 2026 16:56:23 -0400 Subject: [PATCH 04/13] feat(hindsight): decode any declared solver frame first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared decode — the trade as the settling solver's own calldata states it — runs first for every matched transaction, not only Relay's. RelayCalldata is deleted; its logic and guards live venue-agnostic in decoder/declared.rs, with venue fees recorded from address-book data. Netting decoders are the fallback, and every record now carries a decode column: declared (calldata or logs) or netted. The calldata-vs-netting disagreement warning and its second recovery are deleted — declared columns appear only on declared records. --- tools/hindsight/src/decoder/declared.rs | 292 ++++++++++++++++++ tools/hindsight/src/decoder/decode.rs | 28 +- tools/hindsight/src/decoder/intents/cow.rs | 10 +- tools/hindsight/src/decoder/mod.rs | 110 +++---- tools/hindsight/src/decoder/sandwich.rs | 1 + tools/hindsight/src/decoder/solvers/mod.rs | 7 - .../hindsight/src/decoder/venues/coinbase.rs | 4 +- .../hindsight/src/decoder/venues/metamask.rs | 4 +- tools/hindsight/src/decoder/venues/mod.rs | 2 +- tools/hindsight/src/decoder/venues/rabby.rs | 4 +- tools/hindsight/src/decoder/venues/rainbow.rs | 4 +- tools/hindsight/src/decoder/venues/relay.rs | 285 +---------------- tools/hindsight/src/report/record.rs | 1 + tools/hindsight/src/resolve/jsonl.rs | 5 + tools/hindsight/src/resolve/mod.rs | 5 + tools/hindsight/src/telemetry.rs | 1 + tools/hindsight/src/verify/mod.rs | 1 + 17 files changed, 373 insertions(+), 391 deletions(-) create mode 100644 tools/hindsight/src/decoder/declared.rs diff --git a/tools/hindsight/src/decoder/declared.rs b/tools/hindsight/src/decoder/declared.rs new file mode 100644 index 000000000..45f8c0183 --- /dev/null +++ b/tools/hindsight/src/decoder/declared.rs @@ -0,0 +1,292 @@ +//! The declared decode: the trade as the settling solver's own calldata states it. +//! +//! This is the primary decode for every matched transaction, regardless of venue. It reads +//! `token_in`/`token_out`/`amount_in` from the settling solver frame's `SwapIntent` and recovers +//! the settled `amount_out` as the gross amount of `token_out` received by the output recipient — +//! the one field calldata can never carry. The declared amounts are already on the solver-task +//! basis: any venue fee left the input before the solver frame, and the recipient's receipt is +//! the gross output before any output-side fee, so no venue knowledge is needed to decode. Venue +//! fees are still recorded for transparency when the entry point belongs to a known venue. +//! +//! Declines (falling through to the netting decoders) when no solver frame or intent is found, +//! the recipient never received the token, or either guard below fails. + +use alloy::{ + primitives::{Address, U256}, + rpc::types::trace::geth::CallFrame, +}; + +use crate::decoder::{ + decode::TraderFlow, + registry::Registry, + solvers::{self, SwapIntent}, + trace, + transfer_ledger::{NetSwap, TransferLedger}, +}; + +/// Decode a transaction from the settling solver frame's own declaration, returning the flow and +/// the parsed intent (whose declared terms land on the record). +/// +/// The output recipient is the one the solver's calldata declares; a solver whose calldata +/// carries none delivers to the caller, so the transaction sender is the fallback anchor. +/// +/// Two guards protect against the recipient-receipt query mis-attributing a multi-order +/// transaction's output: 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) fn declared_flow( + root: &CallFrame, + registry: &Registry, + transfer_ledger: &TransferLedger, + sender: Address, + entry_point: Address, +) -> Option<(TraderFlow, SwapIntent)> { + let solver_frame = trace::find_solver_frame(root, registry)?; + let solver = registry.solver(solver_frame.to?)?; + let intent = solver + .decoder + .declared_swap(&solver_frame.input, None)?; + let recipient = solver + .decoder + .output_recipient(&solver_frame.input) + .unwrap_or(sender); + + let amount_out = 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; + } + } + + let (venue_fee_in, venue_fee_out) = + venue_fees(registry, entry_point, transfer_ledger, &intent); + let flow = 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, + }; + Some((flow, intent)) +} + +/// The venue fees this transaction paid, when the entry point belongs to a known venue. Recorded +/// for transparency only — the declared amounts are already on the solver-task basis, so neither +/// is adjusted (unlike netting's fee back-out). +fn venue_fees( + registry: &Registry, + entry_point: Address, + transfer_ledger: &TransferLedger, + intent: &SwapIntent, +) -> (Option, Option) { + let Some(venue) = registry + .venue_name(entry_point) + .and_then(|name| registry.venue(name)) + else { + return (None, None); + }; + let fees = transfer_ledger.received_by(&venue.fee_collectors); + let non_zero = |token: &Address| { + fees.get(token) + .copied() + .filter(|fee| !fee.is_zero()) + }; + (non_zero(&intent.token_in), non_zero(&intent.token_out)) +} + +#[cfg(test)] +mod tests { + use alloy::primitives::address; + + 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_swap` 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 + } + + fn relay_collector(registry: &Registry) -> Address { + *registry + .venue("relay") + .unwrap() + .fee_collectors + .iter() + .next() + .unwrap() + } + + #[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, intent) = declared_flow(&root, ®istry, &ledger, sender, ROUTER).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!(intent.min_amount_out, 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!(declared_flow(&root, ®istry, &ledger, sender, ROUTER).is_none()); + } + + #[test] + 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!(declared_flow(&root, ®istry, &ledger, sender, ROUTER).is_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, ROUTER).is_none()); + } + + #[test] + fn test_decode_solver_without_declared_swap_declines() { + // 0x's v4 proxy is a registered solver (matches `find_solver_frame`) but has no + // `declared_swap` 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, ROUTER).is_none()); + } + + #[test] + 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!(declared_flow(&root, ®istry, &ledger, sender, ROUTER).is_none()); + } + + #[test] + fn test_decode_collector_funded_rebalance() { + // The fee collector, not 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 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, _) = declared_flow(&root, ®istry, &ledger, sender, ROUTER).unwrap(); + assert_eq!(flow.tracked, sender); + assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); + } + + #[test] + 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, unlike netting's + // fee back-out. The collectors come from the entry point's venue section in the address + // book; no venue code is involved. + 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, _) = declared_flow(&root, ®istry, &ledger, sender, ROUTER).unwrap(); + assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); + assert_eq!(flow.venue_fee_in, Some(U256::from(40))); + } + + #[test] + fn test_decode_outside_a_venue_records_no_fee() { + // A direct transaction (the entry point is the solver, not a venue): the root frame is + // the solver frame, and there is no venue section to read collectors from, so no fee is + // recorded. + 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))]; + 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, FLY).unwrap(); + assert_eq!(flow.venue_fee_in, None); + assert_eq!(flow.venue_fee_out, None); + } +} diff --git a/tools/hindsight/src/decoder/decode.rs b/tools/hindsight/src/decoder/decode.rs index 67363c196..3486b86ef 100644 --- a/tools/hindsight/src/decoder/decode.rs +++ b/tools/hindsight/src/decoder/decode.rs @@ -18,7 +18,6 @@ use alloy::{ network::AnyTransactionReceipt, primitives::{Address, U256}, providers::Provider, - rpc::types::trace::geth::CallFrame, }; use async_trait::async_trait; @@ -39,6 +38,13 @@ pub(crate) trait TradeDecoder: Send + Sync { /// carried each trade (deliberately not a metric label). fn name(&self) -> &'static str; + /// Whether this decoder reads the trade from declared data (the settlement's own logs or + /// calldata) rather than netting balances. Declared records are the trusted tier; netted + /// records are marked and excluded from the report by default. + fn declares(&self) -> bool { + false + } + async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option; } @@ -85,10 +91,10 @@ fn decoders_for(role: TraderRole<'_>) -> Vec( ctx: &mut DecodeContext<'_, P>, -) -> Option<(&'static str, TraderFlow)> { +) -> Option<(Box>, TraderFlow)> { let role = TraderRole::classify(ctx.entry_point, ctx.registry); if let TraderRole::Venue(name) = role { let registry = ctx.registry; @@ -101,10 +107,10 @@ pub(crate) async fn recover( async fn try_decoders( decoders: Vec>>, ctx: &mut DecodeContext<'_, P>, -) -> Option<(&'static str, TraderFlow)> { +) -> Option<(Box>, TraderFlow)> { for decoder in decoders { if let Some(flow) = decoder.decode(ctx).await { - return Some((decoder.name(), flow)); + return Some((decoder, flow)); } } None @@ -130,10 +136,6 @@ pub(crate) struct DecodeContext<'a, P> { /// 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. @@ -202,7 +204,7 @@ mod tests { use alloy::{providers::RootProvider, rpc::client::RpcClient, transports::mock::Asserter}; use super::*; - use crate::decoder::test_utils::{addr, frame, receipt, swap, tx_hash}; + use crate::decoder::test_utils::{addr, receipt, swap, tx_hash}; /// Always declines. struct Declines; @@ -255,7 +257,6 @@ mod tests { 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, @@ -264,10 +265,11 @@ mod tests { entry_point: addr(2), transfer_ledger: &transfer_ledger, input: &[], - root: &root, venue: None, }; - try_decoders(decoders, &mut ctx).await + try_decoders(decoders, &mut ctx) + .await + .map(|(decoder, flow)| (decoder.name(), flow)) } #[tokio::test] diff --git a/tools/hindsight/src/decoder/intents/cow.rs b/tools/hindsight/src/decoder/intents/cow.rs index 83aee1efa..d1b816b7e 100644 --- a/tools/hindsight/src/decoder/intents/cow.rs +++ b/tools/hindsight/src/decoder/intents/cow.rs @@ -88,6 +88,12 @@ impl TradeDecoder

for CowSettlement { "cow-trade" } + /// The `Trade` event carries the executed amounts and the owner directly — declared data, + /// not netting. + fn declares(&self) -> bool { + true + } + async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { let mut trades = ctx.receipt.logs().iter().filter(|log| { ctx.registry @@ -145,7 +151,7 @@ mod tests { use super::*; use crate::decoder::{ registry::Registry, - test_utils::{addr, frame, receipt, swap, tx_hash}, + test_utils::{addr, receipt, swap, tx_hash}, transfer_ledger::TransferLedger, }; @@ -185,7 +191,6 @@ mod tests { 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, @@ -194,7 +199,6 @@ mod tests { entry_point: COW_SETTLEMENT, transfer_ledger: &transfer_ledger, input: &[], - root: &root, venue: None, }; CowSettlement.decode(&mut ctx).await diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index 7fee6b6dd..54fde27db 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -14,6 +14,7 @@ //! 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 declared; mod decode; mod intents; mod matching; @@ -43,7 +44,7 @@ use anyhow::Context; use tracing::{debug, warn}; use crate::decoder::{ - decode::{recover, DecodeContext, TraderFlow}, + decode::{recover, DecodeContext}, matching::MatchedSolverTrade, solvers::SwapIntent, trace::{collect_native_transfers, fetch_block_traces}, @@ -72,6 +73,10 @@ pub(crate) struct DecodedTrade { /// 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, + /// How this record's amounts were read: `"declared"` (the settling solver's own calldata or + /// logs — the trusted tier) or `"netted"` (balance netting — a fallback whose amounts can be + /// off by an unaccounted fee; the report excludes these by default). + pub decode: &'static str, pub sender: Address, pub token_in: Address, pub token_out: Address, @@ -112,38 +117,6 @@ 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, -) { - 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; - } - 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. fn intent_fields(intent: Option<&SwapIntent>) -> (Option, Option, Option) { @@ -274,24 +247,34 @@ 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, - }; - 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; + // The declared decode runs first, for every matched transaction: the settling solver's + // own calldata is the trusted reading. Netting is the fallback, and its records are + // marked (`decode: "netted"`). + let (decoder, mut flow, intent, decode) = if let Some((flow, intent)) = + declared::declared_flow(root, registry, &transfer_ledger, sender, entry_point) + { + ("solver-calldata", flow, Some(intent), "declared") + } else { + let mut ctx = DecodeContext { + provider, + registry, + code_cache, + receipt, + entry_point, + transfer_ledger: &transfer_ledger, + input: &root.input, + venue: None, + }; + let Some((netting_decoder, 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 decode = if netting_decoder.declares() { "declared" } else { "netted" }; + (netting_decoder.name(), flow, None, decode) }; if let Some(veto) = veto::check(&flow, logs, registry) { @@ -327,30 +310,6 @@ impl Decoder

{ registry, ); - // The trader's swap terms, when the settling solver frame's own calldata declares them. - // 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). Only the netting - // amounts above stay authoritative for what actually settled; this is informational. A - // declared quote that fails the unit-plausibility check against the settled amount is - // dropped (quotes are self-reported); the ABI-decoded terms stay either way. - let intent = trace::find_solver_frame(root, registry) - .and_then(|frame| { - let solver = registry.solver(frame.to?)?; - solver - .decoder - .declared_swap(&frame.input, Some(flow.swap.amount_in)) - }) - .map(|mut intent| { - if let Some(quoted) = intent.declared_quote() { - if !solvers::plausible_quote(quoted, flow.swap.amount_out) { - intent.clear_quote(); - } - } - intent - }); - - 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()); Some(DecodedTrade { @@ -361,6 +320,7 @@ 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, diff --git a/tools/hindsight/src/decoder/sandwich.rs b/tools/hindsight/src/decoder/sandwich.rs index c0c6524c2..dbcc0e828 100644 --- a/tools/hindsight/src/decoder/sandwich.rs +++ b/tools/hindsight/src/decoder/sandwich.rs @@ -276,6 +276,7 @@ mod tests { solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender, token_in: addr(59), token_out, diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index ede84f6d9..9f0a4de08 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -99,13 +99,6 @@ impl SwapIntent { pub(crate) fn declared_quote(&self) -> Option { self.quoted_amount_out } - - /// 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; - } } /// One solver's decoder: everything the solver's own calldata and logs can say about a trade. diff --git a/tools/hindsight/src/decoder/venues/coinbase.rs b/tools/hindsight/src/decoder/venues/coinbase.rs index 396ba5b96..6a761f5f2 100644 --- a/tools/hindsight/src/decoder/venues/coinbase.rs +++ b/tools/hindsight/src/decoder/venues/coinbase.rs @@ -48,7 +48,7 @@ mod tests { use super::*; use crate::decoder::{ registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, + test_utils::{addr, make_transfer_log, receipt, swap, tx_hash}, transfer_ledger::TransferLedger, }; @@ -71,7 +71,6 @@ mod tests { 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, @@ -80,7 +79,6 @@ mod tests { entry_point, transfer_ledger: ledger, input: &[], - root: &root, venue: registry.venue("coinbase"), }; CoinbaseNetting.decode(&mut ctx).await diff --git a/tools/hindsight/src/decoder/venues/metamask.rs b/tools/hindsight/src/decoder/venues/metamask.rs index 524f381a4..5252989a2 100644 --- a/tools/hindsight/src/decoder/venues/metamask.rs +++ b/tools/hindsight/src/decoder/venues/metamask.rs @@ -72,7 +72,7 @@ mod tests { use super::*; use crate::decoder::{ registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, + test_utils::{addr, make_transfer_log, receipt, swap, tx_hash}, transfer_ledger::TransferLedger, }; @@ -107,7 +107,6 @@ mod tests { 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, @@ -116,7 +115,6 @@ mod tests { entry_point, transfer_ledger: ledger, input, - root: &root, venue: registry.venue("metamask"), }; MetaMaskNetting.decode(&mut ctx).await diff --git a/tools/hindsight/src/decoder/venues/mod.rs b/tools/hindsight/src/decoder/venues/mod.rs index eda8c24e8..61c476e23 100644 --- a/tools/hindsight/src/decoder/venues/mod.rs +++ b/tools/hindsight/src/decoder/venues/mod.rs @@ -39,7 +39,7 @@ use crate::decoder::decode::TradeDecoder; /// 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)], + "relay" => vec![Box::new(relay::RelayNetting)], "metamask" => vec![Box::new(metamask::MetaMaskNetting)], "rabby" => vec![Box::new(rabby::RabbyNetting)], "coinbase" => vec![Box::new(coinbase::CoinbaseNetting)], diff --git a/tools/hindsight/src/decoder/venues/rabby.rs b/tools/hindsight/src/decoder/venues/rabby.rs index b3215e591..a4c8f0808 100644 --- a/tools/hindsight/src/decoder/venues/rabby.rs +++ b/tools/hindsight/src/decoder/venues/rabby.rs @@ -68,7 +68,7 @@ mod tests { use super::*; use crate::decoder::{ registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, + test_utils::{addr, make_transfer_log, receipt, swap, tx_hash}, transfer_ledger::TransferLedger, }; @@ -92,7 +92,6 @@ mod tests { 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, @@ -101,7 +100,6 @@ mod tests { entry_point, transfer_ledger: ledger, input: &[], - root: &root, venue: registry.venue("rabby"), }; RabbyNetting.decode(&mut ctx).await diff --git a/tools/hindsight/src/decoder/venues/rainbow.rs b/tools/hindsight/src/decoder/venues/rainbow.rs index 0f9d55c20..a25c76b0b 100644 --- a/tools/hindsight/src/decoder/venues/rainbow.rs +++ b/tools/hindsight/src/decoder/venues/rainbow.rs @@ -70,7 +70,7 @@ mod tests { use super::*; use crate::decoder::{ registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, + test_utils::{addr, make_transfer_log, receipt, swap, tx_hash}, transfer_ledger::TransferLedger, }; @@ -95,7 +95,6 @@ mod tests { 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, @@ -104,7 +103,6 @@ mod tests { entry_point, transfer_ledger: ledger, input, - root: &root, venue: registry.venue("rainbow"), }; RainbowCalldata.decode(&mut ctx).await diff --git a/tools/hindsight/src/decoder/venues/relay.rs b/tools/hindsight/src/decoder/venues/relay.rs index 0f2a46ab8..75939a8bf 100644 --- a/tools/hindsight/src/decoder/venues/relay.rs +++ b/tools/hindsight/src/decoder/venues/relay.rs @@ -1,13 +1,12 @@ -//! Relay decoding. +//! Relay netting. //! //! 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. +//! [`RelayNetting`] is the fallback for Relay transactions the declared decode (see +//! `crate::decoder::declared`) could not read — solvers whose calldata has no parser, or +//! transactions with no solver frame at all. use std::collections::HashSet; @@ -20,87 +19,9 @@ use async_trait::async_trait; use crate::decoder::{ decode::{DecodeContext, TradeDecoder, TraderFlow}, netting_decoders::venue_flow, - solvers, trace, transfer_ledger::{NetSwap, TransferLedger}, }; -/// 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.solver(solver_frame.to?)?; - let intent = solver - .decoder - .declared_swap(&solver_frame.input, None)?; - let recipient = solver - .decoder - .output_recipient(&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()); - - Some(TraderFlow { - tracked: ctx.receipt.from, - 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, - }) - } -} - /// Relay's netting decoder. pub(crate) struct RelayNetting; @@ -215,7 +136,7 @@ mod tests { use super::*; use crate::decoder::{ registry::Registry, - test_utils::{addr, frame, make_transfer_log, receipt, swap, tx_hash}, + test_utils::{addr, make_transfer_log, receipt, swap, tx_hash}, }; fn transfer_ledger(logs: &[Log], native: &[(Address, Address, U256)]) -> TransferLedger { @@ -242,7 +163,6 @@ mod tests { 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, @@ -251,7 +171,6 @@ mod tests { entry_point, transfer_ledger: ledger, input: &[], - root: &root, venue: registry.venue("relay"), }; RelayNetting.decode(&mut ctx).await @@ -436,198 +355,4 @@ mod tests { assert_eq!(flow.venue_fee_out, None); } - 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)); - } - - #[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_rebalance() { - // The fee collector, not 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 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.tracked, sender); - assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); - } - - #[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/report/record.rs b/tools/hindsight/src/report/record.rs index cb0e81d3b..5fbbf6554 100644 --- a/tools/hindsight/src/report/record.rs +++ b/tools/hindsight/src/report/record.rs @@ -85,6 +85,7 @@ mod tests { solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender: Address::ZERO, token_in: weth, token_out: usdc, diff --git a/tools/hindsight/src/resolve/jsonl.rs b/tools/hindsight/src/resolve/jsonl.rs index 14f940c49..fe2cb1b5d 100644 --- a/tools/hindsight/src/resolve/jsonl.rs +++ b/tools/hindsight/src/resolve/jsonl.rs @@ -168,6 +168,7 @@ 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(), @@ -332,6 +333,7 @@ mod tests { solver: "kyberswap".into(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender: Address::ZERO, token_in: Address::ZERO, token_out: Address::repeat_byte(0x22), @@ -408,6 +410,7 @@ mod tests { solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender: Address::ZERO, token_in: weth, token_out: usdc, @@ -531,6 +534,7 @@ mod tests { solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender: Address::ZERO, token_in: Address::repeat_byte(0x11), token_out: Address::repeat_byte(0x22), @@ -588,6 +592,7 @@ mod tests { solver: "1inch".into(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender: Address::ZERO, token_in: Address::repeat_byte(0x11), token_out: Address::repeat_byte(0x22), diff --git a/tools/hindsight/src/resolve/mod.rs b/tools/hindsight/src/resolve/mod.rs index 464a897ce..3b7b9760e 100644 --- a/tools/hindsight/src/resolve/mod.rs +++ b/tools/hindsight/src/resolve/mod.rs @@ -229,6 +229,9 @@ pub(crate) struct RangeComparison { pub solver_source: AttributionSource, /// Which decoder recovered the settled trade. pub decoder: &'static str, + /// How the settled amounts were read: `"declared"` (the solver's own calldata or logs) or + /// `"netted"` (balance netting — excluded from the report by default). + pub decode: &'static str, pub token_in: Address, pub token_out: Address, pub amount_in: U256, @@ -308,6 +311,7 @@ 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, @@ -374,6 +378,7 @@ mod tests { solver: "tycho".into(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender: Address::ZERO, token_in: Address::repeat_byte(0x11), token_out: Address::repeat_byte(0x22), diff --git a/tools/hindsight/src/telemetry.rs b/tools/hindsight/src/telemetry.rs index d9444e3aa..33458c238 100644 --- a/tools/hindsight/src/telemetry.rs +++ b/tools/hindsight/src/telemetry.rs @@ -591,6 +591,7 @@ mod tests { solver: "tycho".into(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender: Address::ZERO, token_in: Address::repeat_byte(0x11), token_out, diff --git a/tools/hindsight/src/verify/mod.rs b/tools/hindsight/src/verify/mod.rs index 9c45a98d0..1889f8e36 100644 --- a/tools/hindsight/src/verify/mod.rs +++ b/tools/hindsight/src/verify/mod.rs @@ -422,6 +422,7 @@ mod tests { solver: solver.to_string(), solver_source: AttributionSource::TraceMatch, decoder: "sender-netting", + decode: "netted", sender: addr(1), token_in, token_out, From c961e64d22beff066a439bf8f5563219f67613c1 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Aug 2026 17:10:02 -0400 Subject: [PATCH 05/13] refactor(hindsight): venues are data; netting is the marked fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Venue decoders are deleted. A venue is now only its address-book section: entry points and fee collectors feed the shared netting fallback and the fee bookkeeping; solver aliases feed attribution. One netting module replaces the three (sender, venue, intent arms picked by the entry point). CoW settlements decode from the Trade log as declared data. TraderRole, DecodeContext, TradeDecoder, and the receipt-only matching module are deleted — matching now also accepts any transaction whose trace contains a known solver frame. Fee-wallet venue attribution checks wallets in address order, so a trade cut by two venues' wallets resolves the same way on every run. --- tools/hindsight/src/decoder/attribution.rs | 595 ++++++++++++++++++ tools/hindsight/src/decoder/declared.rs | 6 +- tools/hindsight/src/decoder/decode.rs | 300 --------- tools/hindsight/src/decoder/intents/mod.rs | 33 - .../hindsight/src/decoder/intents/netting.rs | 225 ------- tools/hindsight/src/decoder/matching.rs | 64 -- tools/hindsight/src/decoder/mod.rs | 249 ++++---- tools/hindsight/src/decoder/netting.rs | 484 ++++++++++++++ .../hindsight/src/decoder/netting_decoders.rs | 114 ---- tools/hindsight/src/decoder/registry.rs | 50 +- .../src/decoder/solvers/attribution.rs | 218 ------- .../src/decoder/{intents => solvers}/cow.rs | 198 +++--- tools/hindsight/src/decoder/solvers/fly.rs | 12 +- tools/hindsight/src/decoder/solvers/mod.rs | 2 +- .../hindsight/src/decoder/transfer_ledger.rs | 102 --- .../src/decoder/venue_attribution.rs | 250 -------- .../hindsight/src/decoder/venues/coinbase.rs | 134 ---- .../hindsight/src/decoder/venues/metamask.rs | 258 -------- tools/hindsight/src/decoder/venues/mod.rs | 69 -- tools/hindsight/src/decoder/venues/rabby.rs | 186 ------ tools/hindsight/src/decoder/venues/rainbow.rs | 147 ----- tools/hindsight/src/decoder/venues/relay.rs | 358 ----------- tools/hindsight/src/decoder/veto.rs | 2 +- 23 files changed, 1321 insertions(+), 2735 deletions(-) create mode 100644 tools/hindsight/src/decoder/attribution.rs delete mode 100644 tools/hindsight/src/decoder/decode.rs delete mode 100644 tools/hindsight/src/decoder/intents/mod.rs delete mode 100644 tools/hindsight/src/decoder/intents/netting.rs delete mode 100644 tools/hindsight/src/decoder/matching.rs create mode 100644 tools/hindsight/src/decoder/netting.rs delete mode 100644 tools/hindsight/src/decoder/netting_decoders.rs delete mode 100644 tools/hindsight/src/decoder/solvers/attribution.rs rename tools/hindsight/src/decoder/{intents => solvers}/cow.rs (52%) delete mode 100644 tools/hindsight/src/decoder/venue_attribution.rs delete mode 100644 tools/hindsight/src/decoder/venues/coinbase.rs delete mode 100644 tools/hindsight/src/decoder/venues/metamask.rs delete mode 100644 tools/hindsight/src/decoder/venues/mod.rs delete mode 100644 tools/hindsight/src/decoder/venues/rabby.rs delete mode 100644 tools/hindsight/src/decoder/venues/rainbow.rs delete mode 100644 tools/hindsight/src/decoder/venues/relay.rs diff --git a/tools/hindsight/src/decoder/attribution.rs b/tools/hindsight/src/decoder/attribution.rs new file mode 100644 index 000000000..19e72c8b0 --- /dev/null +++ b/tools/hindsight/src/decoder/attribution.rs @@ -0,0 +1,595 @@ +//! Attribution: which solver settled a decoded trade, and which venue owns its order flow. +//! +//! Attribution runs after decoding and only labels the record (plus the fee bookkeeping a +//! fee-wallet match implies). Nothing here affects whether a trade decodes. +//! +//! 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, B256, U256}, + rpc::types::trace::geth::CallFrame, + sol, + sol_types::SolCall, +}; +use serde::Serialize; + +use crate::decoder::{ + netting::TraderFlow, registry::Registry, trace, transfer_ledger::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 venue's entry calldata names the solver (`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 venue calldata's own claim (see `venue_declared_solver`), which outranks +/// everything; the remaining tiers read the trace, ending at the entry-point label as the honest +/// "don't know". +pub(crate) fn solver( + 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 } +} + +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); +} + +/// The solver the venue's entry calldata declares, normalized to the address book's solver names +/// via the venue's `solver_aliases` section. `None` when the entry point is not a venue that +/// declares its solver, or the calldata is not the declaring call. +/// +/// `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. +pub(crate) fn venue_declared_solver( + registry: &Registry, + entry_point: Address, + input: &[u8], +) -> Option { + let venue = registry + .venue_name(entry_point) + .and_then(|name| registry.venue(name)) + .filter(|venue| venue.declares_solver())?; + let call = swapCall::abi_decode(input).ok()?; + Some(venue.normalize_solver(&call.aggregatorId)) +} + +/// 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]`), `CoW` `appData` tag +/// (`[venue_appdata]`; the hash is extracted by the caller), fee wallet (`[venue_fees]`), +/// provider integrator tag (`[venue_integrators]`; extracted by the caller). +/// +/// On a fee-wallet match the fee lands on the flow. For netted amounts it is backed out — +/// added back to the output or netted out of the input, whichever side it was taken from. For +/// declared amounts (`amounts_are_declared`), the sides differ: an input-side fee is recorded +/// only (the declared `amount_in` is read after the fee left), but an output-side fee is still +/// grossed back — a fee-wallet venue's wallet is paid from the routing path directly, so the +/// declared recipient's receipt is short of the swap's gross output by exactly the fee. +pub(crate) fn venue( + registry: &Registry, + flow: &mut TraderFlow, + ledger: &TransferLedger, + integrator: Option<&str>, + app_data: Option, + amounts_are_declared: bool, +) -> 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, amounts_are_declared) { + (VenueFee::Input(amount), false) => flow.net_input_fee(amount), + (VenueFee::Input(amount), true) => { + flow.venue_fee_in = flow.venue_fee_in.or(Some(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. 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. +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, Bytes}; + use tycho_simulation::tycho_common::models::Chain; + + use super::*; + use crate::decoder::test_utils::{addr, frame, make_transfer_log, swap, 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 = solver(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 = solver(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 = solver(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 = solver(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 = solver(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 = solver(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 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(None, &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(None, &root, client, sender, ®istry); + assert_eq!(attribution.solver, unknown.to_string()); + assert_eq!(attribution.source, AttributionSource::LargestCall); + } + + fn metamask_router(registry: &Registry) -> Address { + *registry + .venue("metamask") + .unwrap() + .entry_points + .iter() + .next() + .unwrap() + } + + #[test] + fn test_venue_declared_solver_known_ids() { + let registry = Registry::ethereum(); + let router = metamask_router(®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!( + venue_declared_solver(®istry, router, &call.abi_encode()).as_deref(), + Some(want) + ); + } + } + + #[test] + fn test_venue_declared_solver_other_selectors_and_venues() { + let registry = Registry::ethereum(); + let router = metamask_router(®istry); + // Another selector on the declaring venue: no declaration. + assert_eq!(venue_declared_solver(®istry, router, &[0xde, 0xad, 0xbe, 0xef, 0x00]), None); + assert_eq!(venue_declared_solver(®istry, router, &[]), None); + // A venue with no solver aliases (Relay) never declares, even with matching calldata. + let relay = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); + let call = swapCall { + aggregatorId: "oneInchV6FeeDynamic".to_string(), + tokenFrom: addr(10), + amount: U256::from(1000), + data: Bytes::default(), + }; + assert_eq!(venue_declared_solver(®istry, relay, &call.abi_encode()), None); + } + + #[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!(venue(®istry, &mut flow, &ledger, None, None, false).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!(venue(®istry, &mut flow, &ledger, None, None, false), 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!( + venue(®istry, &mut flow, &ledger, None, Some(defillama), false).as_deref(), + Some("llamaswap") + ); + assert_eq!(venue(®istry, &mut flow, &ledger, None, Some(B256::ZERO), false), 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!( + venue(®istry, &mut flow, &ledger, None, None, false).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_fee_wallet_on_declared_amounts() { + // The same Phantom fee leg on a declared decode: the wallet is paid from the routing + // path directly, so the declared recipient's receipt (9915) is short of the gross output + // by the fee — grossed back, exactly like a netted flow. + 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!( + venue(®istry, &mut flow, &ledger, None, None, true).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_fee_wallet_input_fee_on_declared_amounts_is_recorded_only() { + // An input-side wallet fee on a declared decode: the declared amount_in was read from + // the solver frame, after the fee left — netting it out again would double-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 mut flow = TraderFlow::without_fees(user, swap(token_in, 9905, token_out, 2000)); + + assert_eq!( + venue(®istry, &mut flow, &ledger, None, None, true).as_deref(), + Some("coinbase") + ); + assert_eq!(flow.venue_fee_in, Some(U256::from(95))); + assert_eq!(flow.swap.amount_in, U256::from(9905)); + } + + #[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!( + venue(®istry, &mut flow, &ledger, Some("Infinex"), None, false).as_deref(), + Some("infinex") + ); + assert_eq!(venue(®istry, &mut flow, &ledger, Some("somedapp"), None, false), 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!( + venue(®istry, &mut flow, &ledger, Some("base-app"), None, false).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!( + venue(®istry, &mut flow, &ledger, None, None, false).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!(venue(®istry, &mut flow, &ledger, None, None, false), None); + } +} diff --git a/tools/hindsight/src/decoder/declared.rs b/tools/hindsight/src/decoder/declared.rs index 45f8c0183..2a1845293 100644 --- a/tools/hindsight/src/decoder/declared.rs +++ b/tools/hindsight/src/decoder/declared.rs @@ -17,7 +17,7 @@ use alloy::{ }; use crate::decoder::{ - decode::TraderFlow, + netting::TraderFlow, registry::Registry, solvers::{self, SwapIntent}, trace, @@ -62,8 +62,7 @@ pub(crate) fn declared_flow( } } - let (venue_fee_in, venue_fee_out) = - venue_fees(registry, entry_point, transfer_ledger, &intent); + let (venue_fee_in, venue_fee_out) = venue_fees(registry, entry_point, transfer_ledger, &intent); let flow = TraderFlow { tracked: sender, swap: NetSwap { @@ -74,7 +73,6 @@ pub(crate) fn declared_flow( }, venue_fee_in, venue_fee_out, - solver_override: None, }; Some((flow, intent)) } diff --git a/tools/hindsight/src/decoder/decode.rs b/tools/hindsight/src/decoder/decode.rs deleted file mode 100644 index 3486b86ef..000000000 --- a/tools/hindsight/src/decoder/decode.rs +++ /dev/null @@ -1,300 +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, -}; -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; - - /// Whether this decoder reads the trade from declared data (the settlement's own logs or - /// calldata) rather than netting balances. Declared records are the trusted tier; netted - /// records are marked and excluded from the report by default. - fn declares(&self) -> bool { - false - } - - 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 with the flow. -pub(crate) async fn recover( - ctx: &mut DecodeContext<'_, P>, -) -> Option<(Box>, 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<(Box>, TraderFlow)> { - for decoder in decoders { - if let Some(flow) = decoder.decode(ctx).await { - return Some((decoder, 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 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>, -} - -/// The trader's side of a matched transaction: the swap, plus the corrections that make it -/// comparable (venue fees backed out). -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, -} - -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, - } - } - - /// 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, 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 mut ctx = DecodeContext { - provider: &provider, - registry: ®istry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point: addr(2), - transfer_ledger: &transfer_ledger, - input: &[], - venue: None, - }; - try_decoders(decoders, &mut ctx) - .await - .map(|(decoder, flow)| (decoder.name(), flow)) - } - - #[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/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 54fde27db..ce1641c9c 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -1,31 +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. - +//! 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 decode; -mod intents; -mod matching; -mod netting_decoders; +mod netting; mod registry; mod sandwich; mod solvers; mod trace; mod transfer_ledger; -mod venue_attribution; -pub(crate) mod venues; mod veto; #[cfg(test)] @@ -35,7 +38,7 @@ use std::collections::HashMap; use alloy::{ eips::BlockId, - network::AnyTransactionReceipt, + network::{AnyTransactionReceipt, ReceiptResponse}, primitives::{Address, TxHash, U256}, providers::Provider, rpc::types::trace::geth::CallFrame, @@ -43,16 +46,14 @@ use alloy::{ use anyhow::Context; use tracing::{debug, warn}; +pub(crate) use crate::decoder::{ + attribution::AttributionSource, registry::Registry, sandwich::SandwichEvidence, +}; use crate::decoder::{ - decode::{recover, DecodeContext}, - matching::MatchedSolverTrade, solvers::SwapIntent, trace::{collect_native_transfers, fetch_block_traces}, transfer_ledger::TransferLedger, }; -pub(crate) use crate::decoder::{ - registry::Registry, sandwich::SandwichEvidence, solvers::attribution::AttributionSource, -}; /// A decoded solver trade: what token went in, what came out. /// @@ -66,7 +67,7 @@ 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, @@ -97,9 +98,10 @@ pub(crate) struct DecodedTrade { #[serde(skip_serializing_if = "Option::is_none")] pub venue_fee_out: 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_swap` 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 @@ -156,13 +158,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, @@ -182,43 +182,62 @@ 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(); - - // Nothing matched: the block has no solver trades, so its trace is never needed. - if matched.is_empty() { - return Ok(Vec::new()); - } - - // One debug_traceBlockByNumber call covers every matched transaction. A transaction the - // tracer could not process is absent from the map and costs that trade, not the block. + // 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::with_capacity(matched.len()); - for (index, matched) in matched { - let tx_index = matched - .receipt - .transaction_index - .unwrap_or(index as u64); - let Some(root) = roots.remove(&matched.receipt.transaction_hash) else { - warn!( - block = block_number, - tx = %matched.receipt.transaction_hash, - "skipping transaction absent from the block trace" - ); - crate::telemetry::record_untraced_transaction(); + 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() + .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 = %receipt.transaction_hash, + "skipping transaction absent from the block trace" + ); + crate::telemetry::record_untraced_transaction(); + } 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; + } + // Match-time vetoes read logs alone (a solver marking a non-swap order shape). + if let Some(veto) = solvers::solver_veto(receipt.logs(), entry_point, &self.registry) { + debug!( + tx = %receipt.transaction_hash, + venue = %self.registry.label(entry_point), + ?veto, + "matched transaction is not a same-chain swap; skipping" + ); + 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); @@ -229,17 +248,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; @@ -247,35 +266,43 @@ impl Decoder

{ collect_native_transfers(root, &mut native); let transfer_ledger = TransferLedger::from_transaction(logs, &native); - // The declared decode runs first, for every matched transaction: the settling solver's - // own calldata is the trusted reading. Netting is the fallback, and its records are - // marked (`decode: "netted"`). - let (decoder, mut flow, intent, decode) = if let Some((flow, intent)) = - declared::declared_flow(root, registry, &transfer_ledger, sender, entry_point) - { - ("solver-calldata", flow, Some(intent), "declared") - } else { - let mut ctx = DecodeContext { - provider, - registry, - code_cache, - receipt, - entry_point, - transfer_ledger: &transfer_ledger, - input: &root.input, - venue: None, + // The declared decode runs first: the settlement's own data is the trusted reading. + // A batch settler's Trade log wins over the calldata path wherever it appears — the + // settlement can be entered through another contract, and a batch's inner router frames + // are order plumbing, not the trade (which is also why the calldata path never runs on a + // batch-settler entry). Netting is the fallback, and its records are marked. + let declared = solvers::cow::settlement_trade(logs, registry) + .map(|flow| ("cow-trade", flow, None::)) + .or_else(|| { + if registry.is_batch_settler(entry_point) { + return None; + } + declared::declared_flow(root, registry, &transfer_ledger, sender, entry_point) + .map(|(flow, intent)| ("solver-calldata", flow, Some(intent))) + }); + let (decoder, mut flow, intent, amounts_declared) = + if let Some((decoder, flow, intent)) = declared { + (decoder, flow, intent, true) + } 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, false) }; - let Some((netting_decoder, 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 decode = if netting_decoder.declares() { "declared" } else { "netted" }; - (netting_decoder.name(), flow, None, decode) - }; if let Some(veto) = veto::check(&flow, logs, registry) { debug!( @@ -287,30 +314,27 @@ impl Decoder

{ 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. + // A venue fingerprint (owning trader, CoW appData tag, fee wallet, or integrator tag — + // see `attribution`) overrides the entry-point label. The appData tag is read from a + // batch settler's calldata; other transactions carry none. let integrator = solvers::integrator(logs, registry); - let app_data = intents::venue_tag(registry, entry_point, &root.input); - let venue = venue_attribution::attribute( + let app_data = solvers::cow::venue_tag(registry, entry_point, &root.input); + let venue = attribution::venue( registry, &mut flow, &transfer_ledger, integrator.as_deref(), app_data, + amounts_declared, ) .unwrap_or_else(|| registry.label(entry_point)); - let attribution = solvers::attribution::attribute( - flow.solver_override.take(), - root, - entry_point, - sender, - registry, - ); + let declared_solver = + attribution::venue_declared_solver(registry, entry_point, &root.input); + let attribution = attribution::solver(declared_solver, root, entry_point, sender, registry); let (min_amount_out, declared_quote, quote_timestamp) = intent_fields(intent.as_ref()); + let decode = if amounts_declared { "declared" } else { "netted" }; Some(DecodedTrade { tx_hash: receipt.transaction_hash, @@ -378,10 +402,7 @@ mod tests { // 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::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)), diff --git a/tools/hindsight/src/decoder/netting.rs b/tools/hindsight/src/decoder/netting.rs new file mode 100644 index 000000000..6e3675630 --- /dev/null +++ b/tools/hindsight/src/decoder/netting.rs @@ -0,0 +1,484 @@ +//! 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 fee the +//! ledger does not show (or whose collector is not in the address book) sits inside the netted +//! amounts. Netted records are therefore the marked fallback tier (`decode: "netted"`), excluded +//! from the report by default; 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, HashSet}; + +use alloy::{primitives::Address, providers::Provider}; +use tracing::warn; + +use crate::decoder::{ + registry::Registry, + transfer_ledger::{NetSwap, TransferLedger}, +}; + +/// The trader's side of a matched transaction: the swap, plus the venue fees that make it +/// comparable. +pub(crate) struct TraderFlow { + /// The address whose flow the swap was read from. + pub tracked: Address, + pub swap: NetSwap, + /// Venue fee taken from the input token. On a netted flow it is already backed out of + /// `swap.amount_in`; on a declared flow it is recorded only (the declared amount is already + /// post-fee). + pub venue_fee_in: Option, + /// Venue fee taken from the output token. On a netted flow it is already added back into + /// `swap.amount_out`; on a declared flow it is recorded only. + pub venue_fee_out: Option, +} + +impl TraderFlow { + pub(crate) fn without_fees(tracked: Address, swap: NetSwap) -> Self { + Self { tracked, swap, venue_fee_in: None, venue_fee_out: None } + } + + /// 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: alloy::primitives::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. + /// + /// 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: alloy::primitives::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); + } +} + +/// Net the trade the declared decode could not read, picking whose balances count as the trade +/// from the entry point: +/// +/// - a venue entry nets the sender and backs the venue's fee out (collectors from the address +/// book); +/// - a batch settlement or a log-matched intent fill is sent by a solver, so the trader is found in +/// the transfers instead; +/// - a solver entry is a direct swap: the sender is the trader. +/// +/// 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<(&'static str, TraderFlow)> { + if let Some(venue) = registry + .venue_name(entry_point) + .and_then(|name| registry.venue(name)) + { + return venue_flow(transfer_ledger, sender, entry_point, &venue.fee_collectors) + .map(|flow| ("venue-netting", flow)); + } + if registry.is_solver(entry_point) && !registry.is_batch_settler(entry_point) { + return sender_flow(transfer_ledger, sender, entry_point) + .map(|flow| ("sender-netting", flow)); + } + // Batch settlements and log-matched intent fills: the sender acts for the trader. A + // frame-matched transaction through an unknown wrapper has no other trader to find, so it + // falls through to the sender's own flow. + if let Some(flow) = + find_intent_trade(provider, transfer_ledger, &[entry_point, sender], registry, code_cache) + .await + { + return Some(("intent-netting", flow)); + } + sender_flow(transfer_ledger, sender, entry_point).map(|flow| ("sender-netting", 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. +pub(crate) fn sender_flow( + transfer_ledger: &TransferLedger, + sender: Address, + entry_point: Address, +) -> Option { + transfer_ledger + .net_swap(sender) + .map(|swap| 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. +/// +/// 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 flow = sender_flow(transfer_ledger, sender, entry_point)?; + 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( + mut flow: TraderFlow, + transfer_ledger: &TransferLedger, + fee_collectors: &HashSet
, +) -> TraderFlow { + let fees = transfer_ledger.received_by(fee_collectors); + if let Some(fee) = fees + .get(&flow.swap.token_in) + .copied() + .filter(|fee| !fee.is_zero()) + { + flow.net_input_fee(fee); + } + if let Some(fee) = fees + .get(&flow.swap.token_out) + .copied() + .filter(|fee| !fee.is_zero()) + { + flow.gross_output_fee(fee); + } + flow +} + +/// 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())) + } + + fn relay_collector(registry: &Registry) -> Address { + *registry + .venue("relay") + .unwrap() + .fee_collectors + .iter() + .next() + .unwrap() + } + + 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_backs_the_fee_out() { + // User swap through a venue entry point: sender nets token_in -> token_out, with an + // input-side fee to the venue's collector (from the address book). The fee is backed out + // of amount_in. + let registry = Registry::ethereum(); + let collector = relay_collector(®istry); + 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, 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 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, "venue-netting"); + 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); + } + + #[tokio::test] + async fn test_fallback_collector_is_the_trader() { + // Treasury op: the fee collector itself unwraps WETH via the venue 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 = relay_entry(®istry); + 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 ledger = TransferLedger::from_transaction(&logs, &native); + let provider = mocked_provider(&Asserter::new()); + let mut cache = HashMap::new(); + + let (_, flow) = fallback_flow(&provider, &mut cache, ®istry, &ledger, 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_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, "sender-netting"); + assert_eq!(flow.tracked, user); + assert_eq!(flow.swap, 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, "intent-netting"); + assert_eq!(flow.tracked, addr(100)); + } + + #[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/netting_decoders.rs b/tools/hindsight/src/decoder/netting_decoders.rs deleted file mode 100644 index 2ae7a92ce..000000000 --- a/tools/hindsight/src/decoder/netting_decoders.rs +++ /dev/null @@ -1,114 +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, 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::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 flow = sender_flow(transfer_ledger, sender, entry_point)?; - 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, - } -} - -/// 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 c3e2fadc5..98bb5023a 100644 --- a/tools/hindsight/src/decoder/registry.rs +++ b/tools/hindsight/src/decoder/registry.rs @@ -68,9 +68,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. @@ -83,9 +84,9 @@ struct AddressBook { } /// 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`). +/// collectors its fees are sent to, and its calldata solver aliases. Pure data — a venue has no +/// code; decoding is per solver (see `crate::decoder::declared`), and the netting fallback reads +/// the collectors from here. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct VenueAddresses { @@ -104,6 +105,12 @@ impl VenueAddresses { /// 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. + /// Whether this venue declares its solver in the entry calldata (it has alias entries to + /// normalize the declared ids with). + pub(crate) fn declares_solver(&self) -> bool { + !self.solver_aliases.is_empty() + } + pub(crate) fn normalize_solver(&self, id: &str) -> String { let lower = id.to_lowercase(); for (substring, name) in &self.solver_aliases { @@ -164,8 +171,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, @@ -212,18 +220,6 @@ impl Registry { let mut 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 { @@ -334,8 +330,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 } @@ -571,18 +567,6 @@ mod tests { assert!(registry.venue("kyberswap").is_none()); } - #[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/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/intents/cow.rs b/tools/hindsight/src/decoder/solvers/cow.rs similarity index 52% rename from tools/hindsight/src/decoder/intents/cow.rs rename to tools/hindsight/src/decoder/solvers/cow.rs index d1b816b7e..166980ebd 100644 --- a/tools/hindsight/src/decoder/intents/cow.rs +++ b/tools/hindsight/src/decoder/solvers/cow.rs @@ -2,25 +2,24 @@ //! //! `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`). +//! 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 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). +//! 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::{address, Address, B256}, - providers::Provider, + rpc::types::Log, sol, sol_types::{SolCall, SolEvent}, }; -use async_trait::async_trait; use crate::decoder::{ - decode::{DecodeContext, TradeDecoder, TraderFlow}, + netting::TraderFlow, + registry::Registry, transfer_ledger::{to_primitive_log, NetSwap}, }; @@ -65,10 +64,14 @@ sol! { ); } -/// 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 { +/// The settled order's `appData` hash, when the entry point is a batch settler and 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. Venue attribution maps the hash to a +/// venue (`[venue_appdata]`). +pub(crate) fn venue_tag(registry: &Registry, entry_point: Address, input: &[u8]) -> Option { + if !registry.is_batch_settler(entry_point) { + return None; + } let call = settleCall::abi_decode(input).ok()?; let [trade] = call.trades.as_slice() else { return None; @@ -79,53 +82,36 @@ pub(crate) fn order_app_data(input: &[u8]) -> Option { /// `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" - } - - /// The `Trade` event carries the executed amounts and the owner directly — declared data, - /// not netting. - fn declares(&self) -> bool { - true - } - - 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 single settled order's trade, read from the `GPv2` `Trade` event. `None` when no batch +/// settler emitted one, or the batch settles more than one order (left to the netting fallback). +pub(crate) fn settlement_trade(logs: &[Log], registry: &Registry) -> Option { + let mut trades = logs.iter().filter(|log| { + registry.is_batch_settler(log.address()) && + 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); + 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, + }) } fn normalize_native(token: Address) -> Address { @@ -138,22 +124,10 @@ fn normalize_native(token: Address) -> Address { #[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 alloy::primitives::{address, b256, Bytes, U256}; use super::*; - use crate::decoder::{ - registry::Registry, - test_utils::{addr, receipt, swap, tx_hash}, - transfer_ledger::TransferLedger, - }; + use crate::decoder::test_utils::{addr, swap}; /// The Ethereum `CoW` settlement contract (a registered batch settler). const COW_SETTLEMENT: Address = address!("0x9008d19f58aabd9ed0d60971565aa8510560ab41"); @@ -185,71 +159,46 @@ mod tests { 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 mut ctx = DecodeContext { - provider: &provider, - registry: ®istry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point: COW_SETTLEMENT, - transfer_ledger: &transfer_ledger, - input: &[], - venue: None, - }; - CowSettlement.decode(&mut ctx).await + fn decode(logs: &[Log]) -> Option { + settlement_trade(logs, &Registry::ethereum()) } - #[tokio::test] - async fn test_single_order_reads_the_trade_event() { + #[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(vec![trade_log(COW_SETTLEMENT, owner, sell, buy, 1000, 2000, 10)]) - .await - .unwrap(); + let flow = decode(&[trade_log(COW_SETTLEMENT, owner, sell, buy, 1000, 2000, 10)]).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))); } - #[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(); + #[test] + fn test_native_eth_sentinel_normalized() { + let flow = + decode(&[trade_log(COW_SETTLEMENT, addr(100), addr(10), COW_NATIVE_ETH, 1000, 5, 0)]) + .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. + #[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).await.is_none()); + assert!(decode(&logs).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()); + #[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 { @@ -280,18 +229,25 @@ mod tests { #[test] fn test_single_order_reads_app_data() { + let registry = Registry::ethereum(); let app = b256!("0xf249b3db926aa5b5a1b18f3fec86b9cc99b9a8a99ad7e8034242d2838ae97422"); - assert_eq!(order_app_data(&settle_calldata(vec![settle_trade(app)])), Some(app)); + assert_eq!( + venue_tag(®istry, COW_SETTLEMENT, &settle_calldata(vec![settle_trade(app)])), + Some(app) + ); } #[test] fn test_multi_order_batch_has_no_single_app_data() { + let registry = Registry::ethereum(); let trades = vec![settle_trade(B256::ZERO), settle_trade(B256::ZERO)]; - assert!(order_app_data(&settle_calldata(trades)).is_none()); + assert!(venue_tag(®istry, COW_SETTLEMENT, &settle_calldata(trades)).is_none()); } #[test] - fn test_non_settle_calldata_has_no_app_data() { - assert!(order_app_data(&[0u8; 4]).is_none()); + fn test_non_settler_entry_has_no_app_data() { + let registry = Registry::ethereum(); + let app = b256!("0xf249b3db926aa5b5a1b18f3fec86b9cc99b9a8a99ad7e8034242d2838ae97422"); + assert!(venue_tag(®istry, addr(9), &settle_calldata(vec![settle_trade(app)])).is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/fly.rs b/tools/hindsight/src/decoder/solvers/fly.rs index 4cb70225f..fb06759dc 100644 --- a/tools/hindsight/src/decoder/solvers/fly.rs +++ b/tools/hindsight/src/decoder/solvers/fly.rs @@ -164,7 +164,9 @@ mod tests { fn test_wrong_selector() { let mut input = real_input(); input[0] = 0xff; - assert!(Fly.declared_swap(&input, None).is_none()); + assert!(Fly + .declared_swap(&input, None) + .is_none()); } #[test] @@ -190,7 +192,9 @@ 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.declared_swap(&input, None).is_none()); + assert!(Fly + .declared_swap(&input, None) + .is_none()); } #[test] @@ -203,6 +207,8 @@ 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.declared_swap(&input, None).is_none()); + assert!(Fly + .declared_swap(&input, None) + .is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index 9f0a4de08..66d0e7593 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -8,7 +8,7 @@ //! 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 attribution; +pub(crate) mod cow; pub(crate) mod fly; pub(crate) mod kyberswap; pub(crate) mod lifi; diff --git a/tools/hindsight/src/decoder/transfer_ledger.rs b/tools/hindsight/src/decoder/transfer_ledger.rs index 3fae7593a..032380f3b 100644 --- a/tools/hindsight/src/decoder/transfer_ledger.rs +++ b/tools/hindsight/src/decoder/transfer_ledger.rs @@ -176,77 +176,6 @@ impl TransferLedger { } 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 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; - } - } - (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); - } - } - net } /// A net leg is residue when its token routed between third parties and the leg is under this @@ -570,35 +499,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_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 6a761f5f2..000000000 --- a/tools/hindsight/src/decoder/venues/coinbase.rs +++ /dev/null @@ -1,134 +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, 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 mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input: &[], - 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 5252989a2..000000000 --- a/tools/hindsight/src/decoder/venues/metamask.rs +++ /dev/null @@ -1,258 +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::{ - registry::Registry, - test_utils::{addr, 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 mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input, - 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))); - } - - #[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 61c476e23..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::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 a4c8f0808..000000000 --- a/tools/hindsight/src/decoder/venues/rabby.rs +++ /dev/null @@ -1,186 +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::{ - registry::Registry, - test_utils::{addr, 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 mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input: &[], - 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))); - } - - #[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 a25c76b0b..000000000 --- a/tools/hindsight/src/decoder/venues/rainbow.rs +++ /dev/null @@ -1,147 +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, 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 mut ctx = DecodeContext { - provider: &provider, - registry: ®istry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input, - 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 75939a8bf..000000000 --- a/tools/hindsight/src/decoder/venues/relay.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! Relay netting. -//! -//! 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. -//! -//! [`RelayNetting`] is the fallback for Relay transactions the declared decode (see -//! `crate::decoder::declared`) could not read — solvers whose calldata has no parser, or -//! transactions with no solver frame at all. - -use std::collections::HashSet; - -use alloy::{ - primitives::{Address, U256}, - providers::Provider, -}; -use async_trait::async_trait; - -use crate::decoder::{ - decode::{DecodeContext, TradeDecoder, TraderFlow}, - netting_decoders::venue_flow, - transfer_ledger::{NetSwap, TransferLedger}, -}; - -/// 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 token that comes back to the collector (an -/// internal inventory rebalance), or the asset received by the single external recipient that -/// only receives and never sends (a cross-chain order fill; see -/// `TransferLedger::sink_receipts`). -/// -/// 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]; - - // C2 internal rebalance: the collector net-receives exactly one (different) token. - let net_recv: Vec<(Address, U256)> = transfer_ledger - .group_net_received(fee_collectors) - .into_iter() - .collect(); - if !net_recv.is_empty() { - if net_recv.len() != 1 || net_recv[0].0 == token_in { - return None; - } - let (token_out, amount_out) = net_recv[0]; - return Some(NetSwap { token_in, amount_in, token_out, amount_out }); - } - - // 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; - } - outputs.push((token, amount)); - } - if outputs.len() != 1 { - return None; - } - let (token_out, amount_out) = outputs[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::{ - registry::Registry, - test_utils::{addr, 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 mut ctx = DecodeContext { - provider: &provider, - registry, - code_cache: &mut code_cache, - receipt: &receipt, - entry_point, - transfer_ledger: ledger, - input: &[], - 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_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); - } - - #[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); - } - -} diff --git a/tools/hindsight/src/decoder/veto.rs b/tools/hindsight/src/decoder/veto.rs index 4ba834945..5ba86e2c7 100644 --- a/tools/hindsight/src/decoder/veto.rs +++ b/tools/hindsight/src/decoder/veto.rs @@ -17,7 +17,7 @@ use alloy::{ }; use crate::decoder::{ - decode::TraderFlow, + netting::TraderFlow, registry::Registry, transfer_ledger::{NetSwap, Transfer}, }; From f00322240d8c935e18a3bd1faeea2dc654252fd0 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Aug 2026 17:12:10 -0400 Subject: [PATCH 06/13] feat(hindsight): report excludes netted records by default The report's main results cover only declared records; --include-netted adds the marked netting-fallback trades. Records from datasets written before the decode column existed carry no marker and are always kept. --- tools/hindsight/src/report/mod.rs | 51 +++++++++++++++++++++++++++- tools/hindsight/src/report/record.rs | 6 ++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/tools/hindsight/src/report/mod.rs b/tools/hindsight/src/report/mod.rs index 337523acc..c785ffaab 100644 --- a/tools/hindsight/src/report/mod.rs +++ b/tools/hindsight/src/report/mod.rs @@ -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("netted")) + .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. @@ -187,6 +206,36 @@ mod tests { .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 5fbbf6554..67bd600cb 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. From 60c897fd046e8282abac604e338a290dde513232 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Aug 2026 21:03:39 -0400 Subject: [PATCH 07/13] docs(hindsight): solver-first architecture --- tools/CLAUDE.md | 6 +- tools/hindsight/CLAUDE.md | 111 +++++++------- tools/hindsight/README.md | 297 +++++++++++++++----------------------- 3 files changed, 176 insertions(+), 238 deletions(-) diff --git a/tools/CLAUDE.md b/tools/CLAUDE.md index a67f2479f..dddfa2810 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 642f9f6ff..1e1a1d339 100644 --- a/tools/hindsight/CLAUDE.md +++ b/tools/hindsight/CLAUDE.md @@ -16,7 +16,7 @@ and takes neither. Built-in address books: `ethereum`, `base`, `unichain`, `arbitrum`, `bsc`, `polygon`. Any other name needs `--registry`. -- **`decode`** — Fetch block receipts, match solver transactions, trace each one, and emit decoded +- **`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 @@ 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,40 +54,47 @@ 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) | -| `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 (`fly.rs` packed calldata, `kyberswap.rs` ABI `swap` params, `zeroex.rs` `AllowanceHolder.exec`/`Settler.execute`), vetoes and integrator tags (`lifi.rs`), and `cow.rs`'s `Trade`-log read for batch settlements | +| `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 six 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. -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`). +All chain- and protocol-specific data lives in a per-chain TOML, embedded for the six chains +listed above (`registry::BUILTIN_CHAINS`) and loadable via `--registry`. A book carries only the +tiers its chain has; each book's header says what was checked. 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, fee collectors, and — for venues that +declare their solver in calldata — `solver_aliases`), and the venue fingerprints +(`[venue_owners]`, `[venue_fees]`, `[venue_integrators]`, `[venue_appdata]`). ### Re-solve engine (`src/resolve/`) @@ -130,21 +137,20 @@ 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 +### The declared decode -`RelayCalldata` reads `token_in`/`token_out`/`amount_in` from the settling solver frame's own +`declared_flow` 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 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: 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 the netting fallback. The +declared amounts are already on the solver-task basis — a venue's input-side fee left before the +solver frame, and the recipient's receipt is the gross output — so venue fees are recorded via +`venue_fee_in`/`venue_fee_out` for transparency without adjusting the amounts. See +`.claude/plans/calldata-first-decoding.md` for the empirics behind calldata-first ordering: 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. ### Key types @@ -197,27 +203,20 @@ 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, 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_swap` method — plus + `output_recipient` when the calldata names the receiver — registered as one row in + `solvers::IMPLEMENTATIONS`. Add a `veto` method if some of its orders are not same-chain swaps. +- **Venue** (a platform users enter through): a `[venues.]` address-book section — entry + points, fee collectors, and (for venues that declare their solver in calldata) + `solver_aliases`. No code. Verify each fee collector on-chain before adding it: a missing + collector leaves the fee inside the netted amounts (declared amounts are immune). +- **Chain**: a new `registry/.toml` plus its entry in `registry::BUILTIN_CHAINS`, or + passed via `--registry`. Re-verify each venue's fee collectors on that chain. 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 ce801098f..259b982ce 100644 --- a/tools/hindsight/README.md +++ b/tools/hindsight/README.md @@ -14,227 +14,163 @@ 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. A solver's veto + │ (SolverDecoder::veto) rejects non-swap order shapes here. ▼ - ┌─────────────────┐ 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 + fee back-out; 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 (venue-declared id → entry point → trace frame → + │ guess) → sandwich scan ▼ - ┌─────────────────┐ swap_intent ┌─────────────────┐ - │ post-processing │ ───────────────▶ │ SolverKnowledge │ - └────────┬────────┘ └─────────────────┘ - │ veto → venue attribution → solver attribution → 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] -} -``` - + DecodedTrade carries decode: "declared" or "netted" ``` - 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 transaction) - 0x settling your intent order → Intent → the intent decoders (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 -``` - -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: -| 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 | +### The two decode tiers -A decoder may read one of these, several at once, or something else. +**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. The declared amounts are already on the solver-task basis: any venue fee left +the input before the solver frame, so no venue knowledge is needed to decode. These are the +trusted records, and the report's default scope. -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. +**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`. -### Solver knowledge (`solvers/`) +### SolverDecoder (`src/decoder/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), or the integrator tag a frontend records in -the solver's event (LiFi's Diamond). 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 } +trait SolverDecoder { + /// The swap terms in the solver call's own calldata, when parseable. + fn declared_swap(&self, input: &[u8], amount_in_hint: Option) -> Option; + /// The output recipient the calldata declares — whose receipt anchors amount_out. + fn output_recipient(&self, input: &[u8]) -> Option
; + /// Rejects a matched transaction that is not a same-chain swap (checked at match time). + fn veto(&self, logs: &[Log]) -> Option; + /// The frontend tag this solver records in its logs (LiFi fronts other apps). + fn integrator(&self, logs: &[Log]) -> Option; } ``` -### 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. +Every method defaults 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. +Today: Fly (packed calldata), KyberSwap (ABI `swap` params + `clientData` quote), 0x +(`AllowanceHolder.exec` / `Settler.execute`), ParaSwap (quote scan), LiFi (veto + integrator +tag), and CoW's `Trade`-log read keyed by the batch-settler entry. + +### 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 never change the declared amounts: the fee is charged on top whichever solver fills, +so it cancels out of the Fynd comparison. It is recorded on the trade +(`venue_fee_in`/`venue_fee_out`) for transparency, from the venue's fee collectors in the +address book. Only the netting fallback must *back fees out* of its amounts — the reason netted +records are the marked tier. + +### 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). + On a netted flow the fee is backed out of the amounts; on a declared flow it is recorded only. + 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 by that provider's `SolverDecoder::integrator`. + +The solver label comes from its own evidence tiers, most- to least-trusted: the venue-declared +calldata id (MetaMask's `aggregatorId`, normalized via that venue's `solver_aliases`), the entry +point itself, the 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`). ### 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, fee collectors, +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. +always: a diverged fee scheme, with the fee collector missing from that chain's book, nets +trades with the fee still inside the amounts. Those records are marked netted either way, but +fee collectors are still re-verified on every chain a venue is added on. ### 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_swap` (+ `output_recipient` when the calldata names the receiver), 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 | A `veto` method on its `SolverDecoder` impl | Those orders decode as trades that never happened, with absurd rates | +| Add a venue | A `[venues.]` section in the address book — entry points, fee collectors, optional `solver_aliases`. No code | The venue's trades still decode when a known solver's frame or log is inside; the venue label falls back to the raw entry address, and netted amounts keep the venue's fee inside | +| 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 | | 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/`) @@ -290,6 +226,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`. From 4261a92dcf49522192e5f25f61281a9f7710d6e2 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 20 Aug 2026 12:41:08 -0400 Subject: [PATCH 08/13] refactor(hindsight): fold the output recipient into the declared swap A solver's parse now fills the recipient in the same pass. KyberSwap and 0x were ABI-decoding the same calldata twice; Fly parsed twice. The veto and integrator tag stay on the trait, read from logs. --- tools/hindsight/CLAUDE.md | 8 +-- tools/hindsight/README.md | 22 ++++---- tools/hindsight/src/decoder/declared.rs | 5 +- tools/hindsight/src/decoder/solvers/fly.rs | 52 ++++++++----------- .../src/decoder/solvers/kyberswap.rs | 25 +++------ tools/hindsight/src/decoder/solvers/mod.rs | 30 ++++++----- tools/hindsight/src/decoder/solvers/zeroex.rs | 36 ++++++------- 7 files changed, 82 insertions(+), 96 deletions(-) diff --git a/tools/hindsight/CLAUDE.md b/tools/hindsight/CLAUDE.md index 1e1a1d339..d967c5450 100644 --- a/tools/hindsight/CLAUDE.md +++ b/tools/hindsight/CLAUDE.md @@ -91,6 +91,7 @@ All chain- and protocol-specific data lives in a per-chain TOML, embedded for th listed above (`registry::BUILTIN_CHAINS`) and loadable via `--registry`. A book carries only the tiers its chain has; each book's header says what was checked. Sections: `wrapped_native`, `infrastructure` (Permit2 etc.), `usd_stablecoins` (USD anchors for reporting), `batch_settlers`, +`bridge_order_events` (topic0s marking a transaction as not a same-chain swap), `[solvers]` (router address → name; the name joins to a `SolverDecoder` at load), `[labels]` (display-only names), `[venues.]` (entry points, fee collectors, and — for venues that declare their solver in calldata — `solver_aliases`), and the venue fingerprints @@ -207,9 +208,10 @@ It surfaces three ways: - **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_swap` method — plus - `output_recipient` when the calldata names the receiver — registered as one row in - `solvers::IMPLEMENTATIONS`. Add a `veto` method if some of its orders are not same-chain swaps. + of netted: a `SolverDecoder` impl in `solvers/` with a `declared_swap` method, registered as one + row in `solvers::IMPLEMENTATIONS`. One parse fills the whole `SwapIntent`, including the output + recipient when the calldata declares one. If some of its orders are not same-chain swaps, add + the marking event's topic0 to the address book's `bridge_order_events` — no code. - **Venue** (a platform users enter through): a `[venues.]` address-book section — entry points, fee collectors, and (for venues that declare their solver in calldata) `solver_aliases`. No code. Verify each fee collector on-chain before adding it: a missing diff --git a/tools/hindsight/README.md b/tools/hindsight/README.md index 259b982ce..352665294 100644 --- a/tools/hindsight/README.md +++ b/tools/hindsight/README.md @@ -46,7 +46,7 @@ call, so decoding starts there, and the venue is attributed afterwards as a labe │ 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. A solver's veto - │ (SolverDecoder::veto) rejects non-swap order shapes here. + │ A bridge-order event in the logs rejects it here. ▼ ┌─────────────────┐ the settling solver's own declaration: │ declared decode │ calldata — find the solver frame, ask its registry entry's @@ -92,17 +92,20 @@ One trait per solver — everything the solver's own calldata and logs can say: ```rust trait SolverDecoder { - /// The swap terms in the solver call's own calldata, when parseable. + /// The swap terms in the solver call's own calldata — tokens, amounts, the on-chain floor, + /// and (when declared) the off-chain quote and the output recipient whose receipt anchors + /// amount_out. fn declared_swap(&self, input: &[u8], amount_in_hint: Option) -> Option; - /// The output recipient the calldata declares — whose receipt anchors amount_out. - fn output_recipient(&self, input: &[u8]) -> Option
; - /// Rejects a matched transaction that is not a same-chain swap (checked at match time). - fn veto(&self, logs: &[Log]) -> Option; /// The frontend tag this solver records in its logs (LiFi fronts other apps). fn integrator(&self, logs: &[Log]) -> Option; } ``` +The calldata question is one method: a solver's parse fills the whole `SwapIntent` in one pass, +including the recipient. `integrator` is the only log question left on the trait — it decodes a +string out of an event, so it needs code. Rejecting a non-swap order shape needs none: the marking +event's topic0 is address-book data (`bridge_order_events`, read by `Registry::log_veto`). + Every method defaults 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 @@ -165,8 +168,8 @@ fee collectors are still re-verified on every chain a venue is added on. | You want to… | Touch | Without it | |---|---|---| | 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_swap` (+ `output_recipient` when the calldata names the receiver), 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 | A `veto` method on its `SolverDecoder` impl | Those orders decode as trades that never happened, with absurd rates | +| Make a solver's trades declared (trusted) instead of netted | A `SolverDecoder` impl in `solvers/` with `declared_swap`, 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 | The marking event's topic0 in the address book's `bridge_order_events` | Those orders decode as trades that never happened, with absurd rates | | Add a venue | A `[venues.]` section in the address book — entry points, fee collectors, optional `solver_aliases`. No code | The venue's trades still decode when a known solver's frame or log is inside; the venue label falls back to the raw entry address, and netted amounts keep the venue's fee inside | | 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 | | 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 | @@ -201,7 +204,8 @@ 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 +batch settlers, bridge-order event signatures, 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 — and `--chain ` picks one. Pass `--registry ` to extend or replace a book without recompiling. diff --git a/tools/hindsight/src/decoder/declared.rs b/tools/hindsight/src/decoder/declared.rs index 2a1845293..6e7911ffc 100644 --- a/tools/hindsight/src/decoder/declared.rs +++ b/tools/hindsight/src/decoder/declared.rs @@ -47,9 +47,8 @@ pub(crate) fn declared_flow( let intent = solver .decoder .declared_swap(&solver_frame.input, None)?; - let recipient = solver - .decoder - .output_recipient(&solver_frame.input) + let recipient = intent + .output_recipient .unwrap_or(sender); let amount_out = transfer_ledger.received_by_address(recipient, intent.token_out); diff --git a/tools/hindsight/src/decoder/solvers/fly.rs b/tools/hindsight/src/decoder/solvers/fly.rs index fb06759dc..d83a4b44c 100644 --- a/tools/hindsight/src/decoder/solvers/fly.rs +++ b/tools/hindsight/src/decoder/solvers/fly.rs @@ -40,6 +40,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,7 +73,18 @@ 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. @@ -93,21 +106,14 @@ impl SolverDecoder for Fly { return None; } let intent = - SwapIntent::new(data.from_asset, data.to_asset, data.amount_in, data.amount_out_min); + SwapIntent::new(data.from_asset, data.to_asset, data.amount_in, data.amount_out_min) + .with_recipient(data.to_address); 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)] @@ -138,26 +144,14 @@ mod tests { #[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()) + // Relay's own router — the delivery address, not the trader (see `parse`). + let intent = Fly + .declared_swap(&real_input(), None) .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()); + assert_eq!( + intent.output_recipient, + Some(address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f")) + ); } #[test] diff --git a/tools/hindsight/src/decoder/solvers/kyberswap.rs b/tools/hindsight/src/decoder/solvers/kyberswap.rs index 6bf80690f..1d64813bc 100644 --- a/tools/hindsight/src/decoder/solvers/kyberswap.rs +++ b/tools/hindsight/src/decoder/solvers/kyberswap.rs @@ -104,24 +104,20 @@ impl SolverDecoder for Kyberswap { if desc.amount.is_zero() || desc.minReturnAmount.is_zero() { return None; } + // `dstReceiver` — KyberSwap passes this straight down to the inner pool, which pays it + // directly; the router itself never touches the output. let intent = SwapIntent::new( normalize_native(desc.srcToken), normalize_native(desc.dstToken), desc.amount, desc.minReturnAmount, - ); + ) + .with_recipient(desc.dstReceiver); 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) - } } #[cfg(test)] @@ -217,17 +213,10 @@ 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(src, dst, 1_000_000, 990_000, "")) + let intent = Kyberswap + .declared_swap(&swap_calldata(src, dst, 1_000_000, 990_000, ""), None) .unwrap(); - assert_eq!(recipient, Address::repeat_byte(0x77)); - } - - #[test] - fn test_output_recipient_garbage_input() { - assert!(Kyberswap - .output_recipient(&[]) - .is_none()); + assert_eq!(intent.output_recipient, Some(Address::repeat_byte(0x77))); } #[test] diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index 66d0e7593..531746651 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -43,6 +43,11 @@ pub(crate) struct SwapIntent { 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 output recipient the calldata declares, when it carries one — whose receipt the + /// settled amount is read from, since calldata never carries a settled amount. `None` when + /// the solver delivers to the caller implicitly; the caller then anchors on the transaction + /// sender. + pub output_recipient: Option
, /// 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. @@ -66,11 +71,18 @@ impl SwapIntent { token_out, amount_in, min_amount_out, + output_recipient: None, quoted_amount_out: None, timestamp: None, } } + /// 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 + } + /// 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); @@ -106,10 +118,11 @@ impl SwapIntent { /// Every method has a default meaning "this solver's data does not carry that", so a solver only /// implements what its transactions expose; most solvers need no code at all. pub(crate) trait SolverDecoder: 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. Called with - /// the solver frame's input (found via `trace::find_solver_frame`), not the root - /// transaction's — a packed calldata layout (Fly) uses offsets valid only in its own frame. + /// The swap terms encoded in the solver frame's own calldata — including the output + /// recipient, when the calldata declares one — for a solver whose calldata carries them + /// plainly enough to recover without netting a settled amount. Called with the solver frame's + /// input (found via `trace::find_solver_frame`), not the root transaction's: a packed calldata + /// layout (Fly) uses offsets valid only in its own frame. /// /// `amount_in_hint` is a netted input amount, when one is known. Some extractors (`ParaSwap`) /// need it to locate fields by value rather than by ABI offset. @@ -117,15 +130,6 @@ pub(crate) trait SolverDecoder: Send + Sync { None } - /// The address this solver's calldata declares as the output recipient, when it carries one - /// plainly enough to recover — whose receipt the settled amount is read from, since calldata - /// alone never carries a settled amount. Called with the same solver-frame input as - /// `declared_swap`. `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 - } - /// 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 is decoded. diff --git a/tools/hindsight/src/decoder/solvers/zeroex.rs b/tools/hindsight/src/decoder/solvers/zeroex.rs index 4a589aae6..9283ae3e0 100644 --- a/tools/hindsight/src/decoder/solvers/zeroex.rs +++ b/tools/hindsight/src/decoder/solvers/zeroex.rs @@ -115,28 +115,20 @@ impl SolverDecoder for ZeroEx { return None; } let terms = decode_execute(&call.data)?; + // Settler's `AllowedSlippage.recipient` — the address whose receipt anchors the settled + // amount, same as Fly/KyberSwap. let intent = SwapIntent::new( normalize_native(call.token), terms.buy_token, call.amount, terms.min_amount_out, - ); + ) + .with_recipient(terms.recipient); 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 `declared_swap`, 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)] @@ -180,7 +172,10 @@ mod tests { #[test] fn test_real_settled_output_recipient() { - assert_eq!(ZeroEx.output_recipient(&settled_input()), Some(RELAY_ROUTER)); + let intent = ZeroEx + .declared_swap(&settled_input(), None) + .unwrap(); + assert_eq!(intent.output_recipient, Some(RELAY_ROUTER)); } #[test] @@ -199,7 +194,10 @@ mod tests { #[test] fn test_real_reverted_output_recipient() { - assert_eq!(ZeroEx.output_recipient(&reverted_input()), Some(RELAY_ROUTER)); + let intent = ZeroEx + .declared_swap(&reverted_input(), None) + .unwrap(); + assert_eq!(intent.output_recipient, Some(RELAY_ROUTER)); } #[test] @@ -210,9 +208,9 @@ mod tests { } #[test] - fn test_bare_settler_entry_has_no_declared_swap_but_resolves_recipient() { - // A direct `execute` call (no `AllowanceHolder` wrapper): declared_swap 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, @@ -226,7 +224,6 @@ mod tests { assert!(ZeroEx .declared_swap(&input, None) .is_none()); - assert_eq!(ZeroEx.output_recipient(&input), Some(RELAY_ROUTER)); } #[test] @@ -234,9 +231,6 @@ mod tests { assert!(ZeroEx .declared_swap(&[0xde, 0xad, 0xbe, 0xef], None) .is_none()); - assert!(ZeroEx - .output_recipient(&[0xde, 0xad, 0xbe, 0xef]) - .is_none()); } #[test] From fc5fbe303c4bae63b8b8ab208dc2ea3ffe26d0a4 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 20 Aug 2026 15:39:45 -0400 Subject: [PATCH 09/13] refactor(hindsight): move declared-name aliases out of the books The alias table was byte-identical in all five books that have MetaMask: it spells solver names the way a venue decorates them, which is the venue's vocabulary, not the chain's. It becomes one list in solvers/, and a venue section is now just entry points and fee collectors. The read is gated on MetaMask's own entry points, since the sol! declaration is its router's ABI and a declared solver outranks every trace tier. --- tools/hindsight/CLAUDE.md | 7 +- tools/hindsight/README.md | 4 +- tools/hindsight/src/decoder/attribution.rs | 22 +++--- tools/hindsight/src/decoder/registry.rs | 77 +------------------ .../src/decoder/registry/arbitrum.toml | 10 --- .../hindsight/src/decoder/registry/base.toml | 10 --- tools/hindsight/src/decoder/registry/bsc.toml | 10 --- .../src/decoder/registry/ethereum.toml | 19 +---- .../src/decoder/registry/polygon.toml | 10 --- tools/hindsight/src/decoder/solvers/mod.rs | 31 ++++++++ 10 files changed, 56 insertions(+), 144 deletions(-) diff --git a/tools/hindsight/CLAUDE.md b/tools/hindsight/CLAUDE.md index d967c5450..206bdb8aa 100644 --- a/tools/hindsight/CLAUDE.md +++ b/tools/hindsight/CLAUDE.md @@ -93,8 +93,8 @@ tiers its chain has; each book's header says what was checked. Sections: `wrappe `infrastructure` (Permit2 etc.), `usd_stablecoins` (USD anchors for reporting), `batch_settlers`, `bridge_order_events` (topic0s marking a transaction as not a same-chain swap), `[solvers]` (router address → name; the name joins to a `SolverDecoder` at load), `[labels]` -(display-only names), `[venues.]` (entry points, fee collectors, and — for venues that -declare their solver in calldata — `solver_aliases`), and the venue fingerprints +(display-only names), `[venues.]` (entry points and fee collectors), and the venue +fingerprints (`[venue_owners]`, `[venue_fees]`, `[venue_integrators]`, `[venue_appdata]`). ### Re-solve engine (`src/resolve/`) @@ -213,8 +213,7 @@ It surfaces three ways: recipient when the calldata declares one. If some of its orders are not same-chain swaps, add the marking event's topic0 to the address book's `bridge_order_events` — no code. - **Venue** (a platform users enter through): a `[venues.]` address-book section — entry - points, fee collectors, and (for venues that declare their solver in calldata) - `solver_aliases`. No code. Verify each fee collector on-chain before adding it: a missing + points and fee collectors. No code. Verify each fee collector on-chain before adding it: a missing collector leaves the fee inside the netted amounts (declared amounts are immune). - **Chain**: a new `registry/.toml` plus its entry in `registry::BUILTIN_CHAINS`, or passed via `--registry`. Re-verify each venue's fee collectors on that chain. Check the diff --git a/tools/hindsight/README.md b/tools/hindsight/README.md index 352665294..3d4ef8b32 100644 --- a/tools/hindsight/README.md +++ b/tools/hindsight/README.md @@ -147,7 +147,7 @@ four maps from the address book: string mapped to a venue (LiFi frontends), read by that provider's `SolverDecoder::integrator`. The solver label comes from its own evidence tiers, most- to least-trusted: the venue-declared -calldata id (MetaMask's `aggregatorId`, normalized via that venue's `solver_aliases`), the entry +calldata id (MetaMask's `aggregatorId`, normalized through the alias list in `solvers/`), the entry point itself, the 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`). @@ -170,7 +170,7 @@ fee collectors are still re-verified on every chain a venue is added on. | 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_swap`, 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 | The marking event's topic0 in the address book's `bridge_order_events` | Those orders decode as trades that never happened, with absurd rates | -| Add a venue | A `[venues.]` section in the address book — entry points, fee collectors, optional `solver_aliases`. No code | The venue's trades still decode when a known solver's frame or log is inside; the venue label falls back to the raw entry address, and netted amounts keep the venue's fee inside | +| Add a venue | A `[venues.]` section in the address book — entry points and fee collectors. No code | The venue's trades still decode when a known solver's frame or log is inside; the venue label falls back to the raw entry address, and netted amounts keep the venue's fee inside | | 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 | | 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` | The chain has no built-in book and must be passed via `--registry` | diff --git a/tools/hindsight/src/decoder/attribution.rs b/tools/hindsight/src/decoder/attribution.rs index 19e72c8b0..adacc8288 100644 --- a/tools/hindsight/src/decoder/attribution.rs +++ b/tools/hindsight/src/decoder/attribution.rs @@ -20,7 +20,7 @@ use alloy::{ use serde::Serialize; use crate::decoder::{ - netting::TraderFlow, registry::Registry, trace, transfer_ledger::TransferLedger, + netting::TraderFlow, registry::Registry, solvers, trace, transfer_ledger::TransferLedger, }; /// The evidence tier that produced a record's solver label, most- to least-trusted. @@ -87,9 +87,14 @@ sol! { function swap(string aggregatorId, address tokenFrom, uint256 amount, bytes data); } +/// The address-book venue whose router the `swap` call above belongs to. The read is gated on it +/// because a declared solver outranks every trace tier: another venue's router sharing the +/// selector would otherwise override a correct trace attribution. +const DECLARING_VENUE: &str = "metamask"; + /// The solver the venue's entry calldata declares, normalized to the address book's solver names -/// via the venue's `solver_aliases` section. `None` when the entry point is not a venue that -/// declares its solver, or the calldata is not the declaring call. +/// (see `solvers::normalize_declared_name`). `None` when the entry point is not the declaring +/// venue's router, or its calldata is not the declaring call. /// /// `MetaMask` states which solver API it routed through (e.g. "oneInchV6FeeDynamic", /// "uniswapPermit2FeeDynamic"). Trace attribution often cannot resolve these — a token→token @@ -100,12 +105,11 @@ pub(crate) fn venue_declared_solver( entry_point: Address, input: &[u8], ) -> Option { - let venue = registry - .venue_name(entry_point) - .and_then(|name| registry.venue(name)) - .filter(|venue| venue.declares_solver())?; + if registry.venue_name(entry_point) != Some(DECLARING_VENUE) { + return None; + } let call = swapCall::abi_decode(input).ok()?; - Some(venue.normalize_solver(&call.aggregatorId)) + Some(solvers::normalize_declared_name(&call.aggregatorId)) } /// The order-flow venue for a decoded flow, when a fingerprint matches — overriding the @@ -383,7 +387,7 @@ mod tests { // Another selector on the declaring venue: no declaration. assert_eq!(venue_declared_solver(®istry, router, &[0xde, 0xad, 0xbe, 0xef, 0x00]), None); assert_eq!(venue_declared_solver(®istry, router, &[]), None); - // A venue with no solver aliases (Relay) never declares, even with matching calldata. + // Another venue's router (Relay) never declares, even with matching calldata. let relay = address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); let call = swapCall { aggregatorId: "oneInchV6FeeDynamic".to_string(), diff --git a/tools/hindsight/src/decoder/registry.rs b/tools/hindsight/src/decoder/registry.rs index 98bb5023a..d8d06a8dc 100644 --- a/tools/hindsight/src/decoder/registry.rs +++ b/tools/hindsight/src/decoder/registry.rs @@ -83,43 +83,14 @@ 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. Pure data — a venue has no -/// code; decoding is per solver (see `crate::decoder::declared`), and the netting fallback reads -/// the collectors from here. +/// A venue's address-book section on one chain: the contracts users enter through and the +/// collectors its fees are sent to. Pure addresses — a venue has no code; decoding is per solver +/// (see `crate::decoder::declared`), and the netting fallback reads the collectors from here. #[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. - /// Whether this venue declares its solver in the entry calldata (it has alias entries to - /// normalize the declared ids with). - pub(crate) fn declares_solver(&self) -> bool { - !self.solver_aliases.is_empty() - } - - 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. @@ -217,7 +188,7 @@ 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")?; let mut names = book.solvers.clone(); @@ -240,14 +211,6 @@ impl Registry { .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, @@ -495,38 +458,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(); diff --git a/tools/hindsight/src/decoder/registry/arbitrum.toml b/tools/hindsight/src/decoder/registry/arbitrum.toml index 4a6ed5c0e..3c2596b18 100644 --- a/tools/hindsight/src/decoder/registry/arbitrum.toml +++ b/tools/hindsight/src/decoder/registry/arbitrum.toml @@ -98,16 +98,6 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] 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 diff --git a/tools/hindsight/src/decoder/registry/base.toml b/tools/hindsight/src/decoder/registry/base.toml index a160ecb5b..61c64ded0 100644 --- a/tools/hindsight/src/decoder/registry/base.toml +++ b/tools/hindsight/src/decoder/registry/base.toml @@ -74,16 +74,6 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] 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 diff --git a/tools/hindsight/src/decoder/registry/bsc.toml b/tools/hindsight/src/decoder/registry/bsc.toml index d470a1778..01999ba35 100644 --- a/tools/hindsight/src/decoder/registry/bsc.toml +++ b/tools/hindsight/src/decoder/registry/bsc.toml @@ -116,16 +116,6 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] 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. diff --git a/tools/hindsight/src/decoder/registry/ethereum.toml b/tools/hindsight/src/decoder/registry/ethereum.toml index 555f53ac3..122410885 100644 --- a/tools/hindsight/src/decoder/registry/ethereum.toml +++ b/tools/hindsight/src/decoder/registry/ethereum.toml @@ -60,9 +60,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, `fee_collectors` where +# its fees are sent. # 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 @@ -115,19 +115,6 @@ fee_collectors = [ "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 diff --git a/tools/hindsight/src/decoder/registry/polygon.toml b/tools/hindsight/src/decoder/registry/polygon.toml index 065dfe106..da7b56dcf 100644 --- a/tools/hindsight/src/decoder/registry/polygon.toml +++ b/tools/hindsight/src/decoder/registry/polygon.toml @@ -98,16 +98,6 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] 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 diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index 531746651..60d25558f 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -157,6 +157,37 @@ const IMPLEMENTATIONS: &[(&str, &'static dyn SolverDecoder)] = &[ ("0x", &zeroex::ZeroEx), ]; +/// Substrings of the solver ids venues declare in their calldata, mapped to the address book's +/// solver names. A venue decorates the id it was routed through ("oneInchV6FeeDynamic", +/// "uniswapPermit2FeeDynamic"), and the decoration is the venue's, not the chain's — the same +/// vocabulary on every chain — so it lives here rather than in each chain's address book. +const DECLARED_NAME_ALIASES: &[(&str, &str)] = &[ + ("airswap", "airswap"), + ("hashflow", "hashflow"), + ("kyber", "kyberswap"), + ("okx", "okx"), + ("oneinch", "1inch"), + ("openocean", "openocean"), + ("paraswap", "paraswap"), + ("uniswap", "uniswap"), + ("zeroex", "0x"), +]; + +/// Normalize a solver id a venue declared in its calldata to the address book's solver names: +/// the first alias substring contained in the lowercased id names the solver, trimming the +/// venue's decoration. No alias is a substring of another, so the answer does not depend on the +/// order above. An unmatched id passes through as-is — still more informative than a raw +/// executor address, and a signal to extend the list. +pub(crate) fn normalize_declared_name(id: &str) -> String { + let lower = id.to_lowercase(); + for (substring, name) in DECLARED_NAME_ALIASES { + if lower.contains(substring) { + return (*name).to_string(); + } + } + id.to_string() +} + /// 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; From b9e0eb060f52094f0695b940328977b31a132e83 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 20 Aug 2026 16:16:17 -0400 Subject: [PATCH 10/13] refactor(hindsight): stop modelling venue fees A venue fee is charged whichever solver fills the order, so it cancels out of the Fynd comparison. The back-out is gone, fee_collectors leaves the address book, and a venue section is now just its entry points. One correction stays, for a different reason: a fee wallet paid out of the routing path leaves the trader's receipt short of the swap's gross output, while Fynd's quote is gross, so that amount is added back into amount_out. Netted amounts otherwise keep any fee inside them, which is what their marker warns about. On the 16-block sample this moves 8 netted records by the size of the venue's fee and leaves every declared record unchanged. --- tools/hindsight/CLAUDE.md | 25 ++- tools/hindsight/README.md | 47 ++--- tools/hindsight/src/decoder/attribution.rs | 94 ++++------ tools/hindsight/src/decoder/declared.rs | 106 ++++------- tools/hindsight/src/decoder/mod.rs | 15 +- tools/hindsight/src/decoder/netting.rs | 165 +++--------------- tools/hindsight/src/decoder/registry.rs | 17 +- .../src/decoder/registry/arbitrum.toml | 4 - .../hindsight/src/decoder/registry/base.toml | 4 - tools/hindsight/src/decoder/registry/bsc.toml | 4 - .../src/decoder/registry/ethereum.toml | 24 +-- .../src/decoder/registry/polygon.toml | 4 - .../src/decoder/registry/unichain.toml | 2 - tools/hindsight/src/decoder/sandwich.rs | 2 - tools/hindsight/src/decoder/solvers/cow.rs | 13 +- tools/hindsight/src/report/record.rs | 2 - tools/hindsight/src/resolve/jsonl.rs | 8 - tools/hindsight/src/resolve/mod.rs | 2 - tools/hindsight/src/telemetry.rs | 2 - tools/hindsight/src/verify/mod.rs | 2 - 20 files changed, 141 insertions(+), 401 deletions(-) diff --git a/tools/hindsight/CLAUDE.md b/tools/hindsight/CLAUDE.md index 206bdb8aa..e70b41873 100644 --- a/tools/hindsight/CLAUDE.md +++ b/tools/hindsight/CLAUDE.md @@ -91,11 +91,10 @@ All chain- and protocol-specific data lives in a per-chain TOML, embedded for th listed above (`registry::BUILTIN_CHAINS`) and loadable via `--registry`. A book carries only the tiers its chain has; each book's header says what was checked. Sections: `wrapped_native`, `infrastructure` (Permit2 etc.), `usd_stablecoins` (USD anchors for reporting), `batch_settlers`, -`bridge_order_events` (topic0s marking a transaction as not a same-chain swap), `[solvers]` (router address → name; the name joins to a `SolverDecoder` at load), `[labels]` -(display-only names), `[venues.]` (entry points and fee collectors), and the venue -fingerprints -(`[venue_owners]`, `[venue_fees]`, `[venue_integrators]`, `[venue_appdata]`). +(display-only names), `[venues.]` (entry points), and the venue fingerprints +(`[venue_owners]`, `[venue_fees]`, `[venue_integrators]`, `[venue_appdata]`). Venue fees are not +modelled: a fee is charged whichever solver fills, so it cancels out of the comparison. ### Re-solve engine (`src/resolve/`) @@ -146,9 +145,8 @@ the output recipient the same calldata declares (falling back to the transaction 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 the netting fallback. The -declared amounts are already on the solver-task basis — a venue's input-side fee left before the -solver frame, and the recipient's receipt is the gross output — so venue fees are recorded via -`venue_fee_in`/`venue_fee_out` for transparency without adjusting the amounts. See +declared `amount_in` is already on the solver-task basis: an input-side venue fee left before the +solver frame. Venue fees are not modelled otherwise (see the README). See `.claude/plans/calldata-first-decoding.md` for the empirics behind calldata-first ordering: 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. @@ -210,14 +208,13 @@ It surfaces three ways: covers matching, attribution, and metric labels. To make its trades declared (trusted) instead of netted: a `SolverDecoder` impl in `solvers/` with a `declared_swap` method, registered as one row in `solvers::IMPLEMENTATIONS`. One parse fills the whole `SwapIntent`, including the output - recipient when the calldata declares one. If some of its orders are not same-chain swaps, add - the marking event's topic0 to the address book's `bridge_order_events` — no code. -- **Venue** (a platform users enter through): a `[venues.]` address-book section — entry - points and fee collectors. No code. Verify each fee collector on-chain before adding it: a missing - collector leaves the fee inside the netted amounts (declared amounts are immune). + recipient when the calldata declares one. Add a `veto` method if some of its orders are not + same-chain swaps. +- **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]`) instead. - **Chain**: a new `registry/.toml` plus its entry in `registry::BUILTIN_CHAINS`, or - passed via `--registry`. Re-verify each venue's fee collectors on that chain. Check the - monitor's pacing flags (`--max-lag-blocks`) against the chain's block time. The `verify` + 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 3d4ef8b32..d95daad6d 100644 --- a/tools/hindsight/README.md +++ b/tools/hindsight/README.md @@ -46,7 +46,7 @@ call, so decoding starts there, and the venue is attributed afterwards as a labe │ 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. A solver's veto - │ A bridge-order event in the logs rejects it here. + │ SolverDecoder::veto rejects non-swap order shapes here. ▼ ┌─────────────────┐ the settling solver's own declaration: │ declared decode │ calldata — find the solver frame, ask its registry entry's @@ -102,9 +102,8 @@ trait SolverDecoder { ``` The calldata question is one method: a solver's parse fills the whole `SwapIntent` in one pass, -including the recipient. `integrator` is the only log question left on the trait — it decodes a -string out of an event, so it needs code. Rejecting a non-swap order shape needs none: the marking -event's topic0 is address-book data (`bridge_order_events`, read by `Registry::log_veto`). +including the recipient. The other two read logs, and are on the trait because the match step and +venue attribution need protocol knowledge dispatched by address. Every method defaults 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 @@ -122,11 +121,20 @@ give the same solver call different results depending on the wrapper. Decoding b the call once, identically everywhere; the venue is looked up afterwards from the entry point and the registry fingerprints. -Venue fees never change the declared amounts: the fee is charged on top whichever solver fills, -so it cancels out of the Fynd comparison. It is recorded on the trade -(`venue_fee_in`/`venue_fee_out`) for transparency, from the venue's fee collectors in the -address book. Only the netting fallback must *back fees out* of its amounts — the reason netted -records are the marked tier. +### 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. A declared `amount_in` needs no adjustment either: an +input-side fee is taken before the solver frame, so the frame's own figure is already what entered +the swap. + +One correction survives, and it is not about the fee itself. When a known fee wallet +(`[venue_fees]`) is paid out of the routing path, the trader's receipt — netted or declared — is +short of the swap's gross output by that amount, while Fynd's quote is gross. That amount is added +back into `amount_out`, or the comparison would hand Fynd the venue's cut as savings. + +Netted amounts otherwise keep any fee inside them. That is part of what the netted marker warns +about. ### Venue attribution (`attribution.rs`) @@ -155,13 +163,11 @@ record (`solver_source`). ### Per protocol, not per chain 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, fee collectors, -stablecoins — lives in the per-chain address book. +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, nets -trades with the fee still inside the amounts. Those records are marked netted either way, but -fee collectors are still 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? @@ -169,8 +175,8 @@ fee collectors are still re-verified on every chain a venue is added on. |---|---|---| | 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_swap`, 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 | The marking event's topic0 in the address book's `bridge_order_events` | Those orders decode as trades that never happened, with absurd rates | -| Add a venue | A `[venues.]` section in the address book — entry points and fee collectors. No code | The venue's trades still decode when a known solver's frame or log is inside; the venue label falls back to the raw entry address, and netted amounts keep the venue's fee inside | +| Skip a solver's non-swap orders | A `veto` method on its `SolverDecoder` impl | 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 | | 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` | The chain has no built-in book and must be passed via `--registry` | @@ -203,10 +209,9 @@ algorithm. ### The address book (`src/decoder/registry/.toml`) -All chain- and protocol-specific data — solver routers, venue entry points and fee collectors, -batch settlers, bridge-order event signatures, 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, +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 — and `--chain ` picks one. Pass `--registry ` to extend or replace a book without recompiling. diff --git a/tools/hindsight/src/decoder/attribution.rs b/tools/hindsight/src/decoder/attribution.rs index adacc8288..3a4e0f882 100644 --- a/tools/hindsight/src/decoder/attribution.rs +++ b/tools/hindsight/src/decoder/attribution.rs @@ -120,19 +120,17 @@ pub(crate) fn venue_declared_solver( /// (`[venue_appdata]`; the hash is extracted by the caller), fee wallet (`[venue_fees]`), /// provider integrator tag (`[venue_integrators]`; extracted by the caller). /// -/// On a fee-wallet match the fee lands on the flow. For netted amounts it is backed out — -/// added back to the output or netted out of the input, whichever side it was taken from. For -/// declared amounts (`amounts_are_declared`), the sides differ: an input-side fee is recorded -/// only (the declared `amount_in` is read after the fee left), but an output-side fee is still -/// grossed back — a fee-wallet venue's wallet is paid from the routing path directly, so the -/// declared recipient's receipt is short of the swap's gross output by exactly the fee. +/// A fee-wallet match also grosses an output-side fee back into `amount_out`. The wallet is paid +/// from the routing path, so the trader's receipt — netted or declared — is short of the swap's +/// gross output by exactly the fee, and Fynd's quote is gross. Without this the comparison hands +/// Fynd the venue's cut as savings. An input-side fee is not corrected: the declared `amount_in` +/// is already past it, and a netted one carries the marker that says so. pub(crate) fn venue( registry: &Registry, flow: &mut TraderFlow, ledger: &TransferLedger, integrator: Option<&str>, app_data: Option, - amounts_are_declared: bool, ) -> Option { if let Some(venue) = registry.venue_for_owner(flow.tracked) { return Some(venue.to_string()); @@ -142,12 +140,11 @@ pub(crate) fn venue( } if let Some((venue, fee)) = fee_venue(registry, ledger, flow.swap.token_in, flow.swap.token_out) { - match (fee, amounts_are_declared) { - (VenueFee::Input(amount), false) => flow.net_input_fee(amount), - (VenueFee::Input(amount), true) => { - flow.venue_fee_in = flow.venue_fee_in.or(Some(amount)); - } - (VenueFee::Output(amount), _) => flow.gross_output_fee(amount), + if let VenueFee::Output(amount) = fee { + flow.swap.amount_out = flow + .swap + .amount_out + .saturating_add(amount); } return Some(venue); } @@ -404,16 +401,16 @@ mod tests { 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!(venue(®istry, &mut flow, &ledger, None, None, false).as_deref(), Some("kpk")); + let mut flow = TraderFlow::new(kpk_safe, swap(addr(10), 1, addr(11), 2)); + assert_eq!(venue(®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!(venue(®istry, &mut flow, &ledger, None, None, false), None); + let mut flow = TraderFlow::new(addr(9), swap(addr(10), 1, addr(11), 2)); + assert_eq!(venue(®istry, &mut flow, &ledger, None, None), None); } #[test] @@ -423,12 +420,12 @@ mod tests { 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)); + let mut flow = TraderFlow::new(addr(1), swap(addr(10), 1, addr(11), 2)); assert_eq!( - venue(®istry, &mut flow, &ledger, None, Some(defillama), false).as_deref(), + venue(®istry, &mut flow, &ledger, None, Some(defillama)).as_deref(), Some("llamaswap") ); - assert_eq!(venue(®istry, &mut flow, &ledger, None, Some(B256::ZERO), false), None); + assert_eq!(venue(®istry, &mut flow, &ledger, None, Some(B256::ZERO)), None); } #[test] @@ -447,13 +444,9 @@ mod tests { 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)); + let mut flow = TraderFlow::new(user, swap(token_in, 1000, token_out, 9915)); - assert_eq!( - venue(®istry, &mut flow, &ledger, None, None, false).as_deref(), - Some("phantom") - ); - assert_eq!(flow.venue_fee_out, Some(U256::from(85))); + assert_eq!(venue(®istry, &mut flow, &ledger, None, None).as_deref(), Some("phantom")); assert_eq!(flow.swap.amount_out, U256::from(10000)); } @@ -474,13 +467,9 @@ mod tests { 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)); + let mut flow = TraderFlow::new(user, swap(token_in, 1000, token_out, 9915)); - assert_eq!( - venue(®istry, &mut flow, &ledger, None, None, true).as_deref(), - Some("phantom") - ); - assert_eq!(flow.venue_fee_out, Some(U256::from(85))); + assert_eq!(venue(®istry, &mut flow, &ledger, None, None).as_deref(), Some("phantom")); assert_eq!(flow.swap.amount_out, U256::from(10000)); } @@ -500,13 +489,9 @@ mod tests { 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, 9905, token_out, 2000)); + let mut flow = TraderFlow::new(user, swap(token_in, 9905, token_out, 2000)); - assert_eq!( - venue(®istry, &mut flow, &ledger, None, None, true).as_deref(), - Some("coinbase") - ); - assert_eq!(flow.venue_fee_in, Some(U256::from(95))); + assert_eq!(venue(®istry, &mut flow, &ledger, None, None).as_deref(), Some("coinbase")); assert_eq!(flow.swap.amount_in, U256::from(9905)); } @@ -516,19 +501,19 @@ mod tests { // 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)); + let mut flow = TraderFlow::new(addr(1), swap(addr(10), 1, addr(11), 2)); assert_eq!( - venue(®istry, &mut flow, &ledger, Some("Infinex"), None, false).as_deref(), + venue(®istry, &mut flow, &ledger, Some("Infinex"), None).as_deref(), Some("infinex") ); - assert_eq!(venue(®istry, &mut flow, &ledger, Some("somedapp"), None, false), None); + assert_eq!(venue(®istry, &mut flow, &ledger, Some("somedapp"), None), None); } #[test] - fn test_fee_wallet_input_side_fee_nets_the_input_down() { + fn test_fee_wallet_input_side_fee_identifies_without_adjusting() { // 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. + // routing. The wallet identifies the venue, but the amount is left as netted — a declared + // amount_in is already past the fee, and a netted one carries the marker that says so. let registry = Registry::builtin(Chain::Bsc).unwrap(); let coinbase = address!("0x5aafc1f252d544f744d17a4e734afd6efc47ede4"); let user = addr(1); @@ -541,16 +526,14 @@ mod tests { 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)); + let mut flow = TraderFlow::new(user, swap(token_in, 10000, token_out, 2000)); assert_eq!( - venue(®istry, &mut flow, &ledger, Some("base-app"), None, false).as_deref(), + venue(®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)); + assert_eq!(flow.swap.amount_in, U256::from(10000)); // 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)); } @@ -568,15 +551,10 @@ mod tests { 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)); + let mut flow = TraderFlow::new(user, swap(token_in, 1000, token_out, 9915)); - assert_eq!( - venue(®istry, &mut flow, &ledger, None, None, false).as_deref(), - Some("phantom") - ); - assert_eq!(flow.venue_fee_out, Some(U256::from(85))); + assert_eq!(venue(®istry, &mut flow, &ledger, None, None).as_deref(), Some("phantom")); 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)); } @@ -593,7 +571,7 @@ mod tests { 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!(venue(®istry, &mut flow, &ledger, None, None, false), None); + let mut flow = TraderFlow::new(user, swap(token_in, 1000, token_out, 2000)); + assert_eq!(venue(®istry, &mut flow, &ledger, None, None), None); } } diff --git a/tools/hindsight/src/decoder/declared.rs b/tools/hindsight/src/decoder/declared.rs index 6e7911ffc..aa16bcccb 100644 --- a/tools/hindsight/src/decoder/declared.rs +++ b/tools/hindsight/src/decoder/declared.rs @@ -11,10 +11,7 @@ //! Declines (falling through to the netting decoders) when no solver frame or intent is found, //! the recipient never received the token, or either guard below fails. -use alloy::{ - primitives::{Address, U256}, - rpc::types::trace::geth::CallFrame, -}; +use alloy::{primitives::Address, rpc::types::trace::geth::CallFrame}; use crate::decoder::{ netting::TraderFlow, @@ -40,7 +37,6 @@ pub(crate) fn declared_flow( registry: &Registry, transfer_ledger: &TransferLedger, sender: Address, - entry_point: Address, ) -> Option<(TraderFlow, SwapIntent)> { let solver_frame = trace::find_solver_frame(root, registry)?; let solver = registry.solver(solver_frame.to?)?; @@ -61,48 +57,21 @@ pub(crate) fn declared_flow( } } - let (venue_fee_in, venue_fee_out) = venue_fees(registry, entry_point, transfer_ledger, &intent); - let flow = TraderFlow { - tracked: sender, - swap: NetSwap { + let flow = TraderFlow::new( + sender, + NetSwap { token_in: intent.token_in, amount_in: intent.amount_in, token_out: intent.token_out, amount_out, }, - venue_fee_in, - venue_fee_out, - }; + ); Some((flow, intent)) } -/// The venue fees this transaction paid, when the entry point belongs to a known venue. Recorded -/// for transparency only — the declared amounts are already on the solver-task basis, so neither -/// is adjusted (unlike netting's fee back-out). -fn venue_fees( - registry: &Registry, - entry_point: Address, - transfer_ledger: &TransferLedger, - intent: &SwapIntent, -) -> (Option, Option) { - let Some(venue) = registry - .venue_name(entry_point) - .and_then(|name| registry.venue(name)) - else { - return (None, None); - }; - let fees = transfer_ledger.received_by(&venue.fee_collectors); - let non_zero = |token: &Address| { - fees.get(token) - .copied() - .filter(|fee| !fee.is_zero()) - }; - (non_zero(&intent.token_in), non_zero(&intent.token_out)) -} - #[cfg(test)] mod tests { - use alloy::primitives::address; + use alloy::primitives::{address, U256}; use super::*; use crate::decoder::test_utils::{addr, frame, make_transfer_log}; @@ -137,16 +106,6 @@ mod tests { root } - fn relay_collector(registry: &Registry) -> Address { - *registry - .venue("relay") - .unwrap() - .fee_collectors - .iter() - .next() - .unwrap() - } - #[test] fn test_decode_recovers_output_from_recipient_receipt() { // The router — the declared recipient — receives native ETH above the floor; the @@ -158,7 +117,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; let ledger = TransferLedger::from_transaction(&logs, &native); - let (flow, intent) = declared_flow(&root, ®istry, &ledger, sender, ROUTER).unwrap(); + let (flow, intent) = declared_flow(&root, ®istry, &ledger, sender).unwrap(); assert_eq!(flow.tracked, sender); assert_eq!(flow.swap.token_in, TOKEN_IN); assert_eq!(flow.swap.token_out, Address::ZERO); @@ -177,7 +136,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT - 1))]; let ledger = TransferLedger::from_transaction(&[], &native); - assert!(declared_flow(&root, ®istry, &ledger, sender, ROUTER).is_none()); + assert!(declared_flow(&root, ®istry, &ledger, sender).is_none()); } #[test] @@ -187,7 +146,7 @@ mod tests { let root = root_with_solver_frame(sender, ROUTER, FLY); let ledger = TransferLedger::from_transaction(&[], &[]); - assert!(declared_flow(&root, ®istry, &ledger, sender, ROUTER).is_none()); + assert!(declared_flow(&root, ®istry, &ledger, sender).is_none()); } #[test] @@ -198,7 +157,7 @@ mod tests { 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, ROUTER).is_none()); + assert!(declared_flow(&root, ®istry, &ledger, sender).is_none()); } #[test] @@ -212,7 +171,7 @@ mod tests { 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, ROUTER).is_none()); + assert!(declared_flow(&root, ®istry, &ledger, sender).is_none()); } #[test] @@ -227,63 +186,60 @@ mod tests { let native = vec![(addr(50), ROUTER, implausible)]; let ledger = TransferLedger::from_transaction(&[], &native); - assert!(declared_flow(&root, ®istry, &ledger, sender, ROUTER).is_none()); + assert!(declared_flow(&root, ®istry, &ledger, sender).is_none()); } #[test] - fn test_decode_collector_funded_rebalance() { - // The fee collector, not the sender, net-sends the input token: a solver-initiated - // rebalance still decodes from the calldata, with the intent's own amounts. + 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 collector = relay_collector(®istry); + let funder = addr(99); let root = root_with_solver_frame(sender, ROUTER, FLY); - let logs = vec![make_transfer_log(TOKEN_IN, collector, ROUTER, U256::from(AMOUNT_IN))]; + 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, ROUTER).unwrap(); + let (flow, _) = declared_flow(&root, ®istry, &ledger, sender).unwrap(); assert_eq!(flow.tracked, sender); assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); } #[test] - 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, unlike netting's - // fee back-out. The collectors come from the entry point's venue section in the address - // book; no venue code is involved. + fn test_decode_ignores_an_input_side_fee_leg() { + // An input-side fee leg on the way to the solver: `amount_in` stays the frame's own + // figure, which is already what reached the solver. 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)), + 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, ROUTER).unwrap(); + let (flow, _) = declared_flow(&root, ®istry, &ledger, sender).unwrap(); assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); - assert_eq!(flow.venue_fee_in, Some(U256::from(40))); } #[test] - fn test_decode_outside_a_venue_records_no_fee() { - // A direct transaction (the entry point is the solver, not a venue): the root frame is - // the solver frame, and there is no venue section to read collectors from, so no fee is - // recorded. + 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, FLY).unwrap(); - assert_eq!(flow.venue_fee_in, None); - assert_eq!(flow.venue_fee_out, None); + let (flow, _) = declared_flow(&root, ®istry, &ledger, sender).unwrap(); + assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); + assert_eq!(flow.swap.amount_out, U256::from(MIN_AMOUNT_OUT + 1_000)); } } diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index ce1641c9c..f708108d4 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -87,16 +87,6 @@ pub(crate) struct DecodedTrade { /// 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. 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, /// The on-chain enforced floor declared in the settling solver frame's own calldata (see /// `SolverDecoder::declared_swap` 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 @@ -277,7 +267,7 @@ impl Decoder

{ if registry.is_batch_settler(entry_point) { return None; } - declared::declared_flow(root, registry, &transfer_ledger, sender, entry_point) + declared::declared_flow(root, registry, &transfer_ledger, sender) .map(|(flow, intent)| ("solver-calldata", flow, Some(intent))) }); let (decoder, mut flow, intent, amounts_declared) = @@ -325,7 +315,6 @@ impl Decoder

{ &transfer_ledger, integrator.as_deref(), app_data, - amounts_declared, ) .unwrap_or_else(|| registry.label(entry_point)); @@ -350,8 +339,6 @@ impl Decoder

{ 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, min_amount_out, declared_quote, quote_timestamp, diff --git a/tools/hindsight/src/decoder/netting.rs b/tools/hindsight/src/decoder/netting.rs index 6e3675630..4a01fe03e 100644 --- a/tools/hindsight/src/decoder/netting.rs +++ b/tools/hindsight/src/decoder/netting.rs @@ -2,16 +2,17 @@ //! //! 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 fee the -//! ledger does not show (or whose collector is not in the address book) sits inside the netted -//! amounts. Netted records are therefore the marked fallback tier (`decode: "netted"`), excluded -//! from the report by default; the declared decode (see `super::declared`) is the trusted path. +//! 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. Netted records are therefore the marked fallback tier (`decode: "netted"`), +//! excluded from the report by default; the declared decode (see `super::declared`) is the trusted +//! path, and its `amount_in` is already past any input-side fee. //! //! 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, HashSet}; +use std::collections::HashMap; use alloy::{primitives::Address, providers::Provider}; use tracing::warn; @@ -21,60 +22,25 @@ use crate::decoder::{ transfer_ledger::{NetSwap, TransferLedger}, }; -/// The trader's side of a matched transaction: the swap, plus the venue fees that make it -/// comparable. +/// The trader's side of a matched transaction. pub(crate) struct TraderFlow { /// The address whose flow the swap was read from. pub tracked: Address, pub swap: NetSwap, - /// Venue fee taken from the input token. On a netted flow it is already backed out of - /// `swap.amount_in`; on a declared flow it is recorded only (the declared amount is already - /// post-fee). - pub venue_fee_in: Option, - /// Venue fee taken from the output token. On a netted flow it is already added back into - /// `swap.amount_out`; on a declared flow it is recorded only. - pub venue_fee_out: Option, } impl TraderFlow { - pub(crate) fn without_fees(tracked: Address, swap: NetSwap) -> Self { - Self { tracked, swap, venue_fee_in: None, venue_fee_out: None } - } - - /// 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: alloy::primitives::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. - /// - /// 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: alloy::primitives::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); + pub(crate) fn new(tracked: Address, swap: NetSwap) -> Self { + Self { tracked, swap } } } /// Net the trade the declared decode could not read, picking whose balances count as the trade /// from the entry point: /// -/// - a venue entry nets the sender and backs the venue's fee out (collectors from the address -/// book); +/// - 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; -/// - a solver entry is a direct swap: the sender is the trader. +/// the transfers instead. /// /// Returns the decoder label recorded on the trade with the flow. pub(crate) async fn fallback_flow( @@ -85,11 +51,11 @@ pub(crate) async fn fallback_flow( sender: Address, entry_point: Address, ) -> Option<(&'static str, TraderFlow)> { - if let Some(venue) = registry + if registry .venue_name(entry_point) - .and_then(|name| registry.venue(name)) + .is_some() { - return venue_flow(transfer_ledger, sender, entry_point, &venue.fee_collectors) + return sender_flow(transfer_ledger, sender, entry_point) .map(|flow| ("venue-netting", flow)); } if registry.is_solver(entry_point) && !registry.is_batch_settler(entry_point) { @@ -118,62 +84,14 @@ pub(crate) fn sender_flow( ) -> Option { transfer_ledger .net_swap(sender) - .map(|swap| TraderFlow::without_fees(sender, swap)) + .map(|swap| TraderFlow::new(sender, swap)) .or_else(|| { transfer_ledger .net_swap(entry_point) - .map(|swap| TraderFlow::without_fees(entry_point, swap)) + .map(|swap| TraderFlow::new(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. -/// -/// 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 flow = sender_flow(transfer_ledger, sender, entry_point)?; - 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( - mut flow: TraderFlow, - transfer_ledger: &TransferLedger, - fee_collectors: &HashSet
, -) -> TraderFlow { - let fees = transfer_ledger.received_by(fee_collectors); - if let Some(fee) = fees - .get(&flow.swap.token_in) - .copied() - .filter(|fee| !fee.is_zero()) - { - flow.net_input_fee(fee); - } - if let Some(fee) = fees - .get(&flow.swap.token_out) - .copied() - .filter(|fee| !fee.is_zero()) - { - flow.gross_output_fee(fee); - } - flow -} - /// 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 @@ -202,7 +120,7 @@ pub(crate) async fn find_intent_trade( ) -> 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)); + return Some(TraderFlow::new(candidate, trade)); } } None @@ -270,16 +188,6 @@ mod tests { RootProvider::new(RpcClient::mocked(asserter.clone())) } - fn relay_collector(registry: &Registry) -> Address { - *registry - .venue("relay") - .unwrap() - .fee_collectors - .iter() - .next() - .unwrap() - } - fn relay_entry(registry: &Registry) -> Address { *registry .venue("relay") @@ -301,12 +209,11 @@ mod tests { } #[tokio::test] - async fn test_fallback_venue_entry_backs_the_fee_out() { - // User swap through a venue entry point: sender nets token_in -> token_out, with an - // input-side fee to the venue's collector (from the address book). The fee is backed out - // of amount_in. + 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 collector = relay_collector(®istry); let router = relay_entry(®istry); let user = addr(1); let pool = addr(50); @@ -315,7 +222,7 @@ mod tests { 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, 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)), ]; @@ -329,33 +236,7 @@ mod tests { .unwrap(); assert_eq!(decoder, "venue-netting"); 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); - } - - #[tokio::test] - async fn test_fallback_collector_is_the_trader() { - // Treasury op: the fee collector itself unwraps WETH via the venue 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 = relay_entry(®istry); - 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 ledger = TransferLedger::from_transaction(&logs, &native); - let provider = mocked_provider(&Asserter::new()); - let mut cache = HashMap::new(); - - let (_, flow) = fallback_flow(&provider, &mut cache, ®istry, &ledger, 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); + assert_eq!(flow.swap, swap(token_in, 1000, token_out, 2000)); } #[tokio::test] diff --git a/tools/hindsight/src/decoder/registry.rs b/tools/hindsight/src/decoder/registry.rs index d8d06a8dc..f8171cbc0 100644 --- a/tools/hindsight/src/decoder/registry.rs +++ b/tools/hindsight/src/decoder/registry.rs @@ -83,14 +83,13 @@ struct AddressBook { venue_appdata: HashMap, } -/// A venue's address-book section on one chain: the contracts users enter through and the -/// collectors its fees are sent to. Pure addresses — a venue has no code; decoding is per solver -/// (see `crate::decoder::declared`), and the netting fallback reads the collectors from here. +/// 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: an amount is either declared by +/// the solver's own calldata (already past any venue fee) or netted and marked as such. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct VenueAddresses { pub(crate) entry_points: HashSet
, - pub(crate) fee_collectors: HashSet
, } /// A loaded solver entry: its display name joined with its `SolverDecoder` implementation. @@ -481,12 +480,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")); @@ -494,7 +488,10 @@ 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()); } diff --git a/tools/hindsight/src/decoder/registry/arbitrum.toml b/tools/hindsight/src/decoder/registry/arbitrum.toml index 3c2596b18..2172a7cbd 100644 --- a/tools/hindsight/src/decoder/registry/arbitrum.toml +++ b/tools/hindsight/src/decoder/registry/arbitrum.toml @@ -89,14 +89,12 @@ 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"] # Rabby SwapProxy — same address as mainnet. Verified on-chain: fee legs paid to 0xcd6b9800… @@ -105,13 +103,11 @@ fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] # Rabby swaps are recognised by the fee leg (see rabby.rs), 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). [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 61c64ded0..bbb765ebf 100644 --- a/tools/hindsight/src/decoder/registry/base.toml +++ b/tools/hindsight/src/decoder/registry/base.toml @@ -64,7 +64,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,7 +71,6 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # 0xb1aa0a09d43b6b4f289ef14f2441339acdb551ac is NOT the live entry point on Base. [venues.metamask] entry_points = ["0xdb9b1e94b5b69df7e401ddbede43491141047db3"] -fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] # Rabby SwapProxy — same address as mainnet, deployed on Base. Verified on-chain: 837 fee legs in @@ -82,13 +80,11 @@ fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] # are recognised by the fee leg (see rabby.rs), 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). [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 01999ba35..5209ee2c0 100644 --- a/tools/hindsight/src/decoder/registry/bsc.toml +++ b/tools/hindsight/src/decoder/registry/bsc.toml @@ -106,7 +106,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,7 +113,6 @@ 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"] # Rabby SwapProxy — same address as mainnet. Verified on-chain: 218 fee legs paid to 0xcd6b9800… @@ -123,13 +121,11 @@ fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] # fee leg (see rabby.rs), 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). [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 122410885..c1f9120f9 100644 --- a/tools/hindsight/src/decoder/registry/ethereum.toml +++ b/tools/hindsight/src/decoder/registry/ethereum.toml @@ -61,8 +61,8 @@ batch_settlers = ["0x9008d19f58aabd9ed0d60971565aa8510560ab41"] "0x20f6ee51340adeed01a59b0e65cb3703f3dc860c" = "fly" # 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, `fee_collectors` where -# its fees are sent. +# 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,20 +100,10 @@ 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", -] [venues.rabby] @@ -121,13 +111,6 @@ fee_collectors = [ # 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). 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 @@ -138,15 +121,12 @@ 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. entry_points = ["0x00000000009726632680fb29d3f7a9734e3010e2"] -fee_collectors = [] # 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 da7b56dcf..5a2d763df 100644 --- a/tools/hindsight/src/decoder/registry/polygon.toml +++ b/tools/hindsight/src/decoder/registry/polygon.toml @@ -88,7 +88,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,7 +95,6 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # 0xe3478b0b… (mainnet fee wallet 1); mainnet wallet 2 took nothing. [venues.metamask] entry_points = ["0xdb9b1e94b5b69df7e401ddbede43491141047db3"] -fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] # Rabby SwapProxy — same address as mainnet. Verified on-chain: 69 fee legs paid to 0xcd6b9800… @@ -105,13 +103,11 @@ fee_collectors = ["0xe3478b0bb1a5084567c319096437924948be1964"] # recognised by the fee leg (see rabby.rs), 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). [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/unichain.toml b/tools/hindsight/src/decoder/registry/unichain.toml index 951dfdd39..abcceffbf 100644 --- a/tools/hindsight/src/decoder/registry/unichain.toml +++ b/tools/hindsight/src/decoder/registry/unichain.toml @@ -75,7 +75,6 @@ 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 @@ -83,7 +82,6 @@ fee_collectors = ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"] # Rabby swaps are recognised by the fee leg (see rabby.rs), 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 dbcc0e828..0940cf348 100644 --- a/tools/hindsight/src/decoder/sandwich.rs +++ b/tools/hindsight/src/decoder/sandwich.rs @@ -282,8 +282,6 @@ mod tests { token_out, amount_in: U256::from(1_000u64), amount_out: U256::from(2_000u64), - venue_fee_in: None, - venue_fee_out: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, diff --git a/tools/hindsight/src/decoder/solvers/cow.rs b/tools/hindsight/src/decoder/solvers/cow.rs index 166980ebd..e4bd5ff08 100644 --- a/tools/hindsight/src/decoder/solvers/cow.rs +++ b/tools/hindsight/src/decoder/solvers/cow.rs @@ -100,18 +100,15 @@ pub(crate) fn settlement_trade(logs: &[Log], registry: &Registry) -> Option Address { @@ -172,7 +169,6 @@ mod tests { let flow = decode(&[trade_log(COW_SETTLEMENT, owner, sell, buy, 1000, 2000, 10)]).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))); } #[test] @@ -181,7 +177,6 @@ mod tests { decode(&[trade_log(COW_SETTLEMENT, addr(100), addr(10), COW_NATIVE_ETH, 1000, 5, 0)]) .unwrap(); assert_eq!(flow.swap.token_out, Address::ZERO); - assert_eq!(flow.venue_fee_in, None); } #[test] diff --git a/tools/hindsight/src/report/record.rs b/tools/hindsight/src/report/record.rs index 67bd600cb..4ecd6f8b3 100644 --- a/tools/hindsight/src/report/record.rs +++ b/tools/hindsight/src/report/record.rs @@ -97,8 +97,6 @@ mod tests { 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, min_amount_out: None, declared_quote: None, quote_timestamp: None, diff --git a/tools/hindsight/src/resolve/jsonl.rs b/tools/hindsight/src/resolve/jsonl.rs index fe2cb1b5d..8d8108058 100644 --- a/tools/hindsight/src/resolve/jsonl.rs +++ b/tools/hindsight/src/resolve/jsonl.rs @@ -339,8 +339,6 @@ mod tests { 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, 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), @@ -416,8 +414,6 @@ mod tests { 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, min_amount_out: None, declared_quote: None, quote_timestamp: None, @@ -540,8 +536,6 @@ mod tests { 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, min_amount_out: None, declared_quote: None, quote_timestamp: None, @@ -598,8 +592,6 @@ mod tests { 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, min_amount_out: None, declared_quote: None, quote_timestamp: None, diff --git a/tools/hindsight/src/resolve/mod.rs b/tools/hindsight/src/resolve/mod.rs index 3b7b9760e..770b0b38a 100644 --- a/tools/hindsight/src/resolve/mod.rs +++ b/tools/hindsight/src/resolve/mod.rs @@ -384,8 +384,6 @@ mod tests { 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, min_amount_out: None, declared_quote: None, quote_timestamp: None, diff --git a/tools/hindsight/src/telemetry.rs b/tools/hindsight/src/telemetry.rs index 33458c238..4bc21b851 100644 --- a/tools/hindsight/src/telemetry.rs +++ b/tools/hindsight/src/telemetry.rs @@ -597,8 +597,6 @@ mod tests { token_out, amount_in: U256::from(1_000u64), amount_out: U256::from(settled), - venue_fee_in: None, - venue_fee_out: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, diff --git a/tools/hindsight/src/verify/mod.rs b/tools/hindsight/src/verify/mod.rs index 1889f8e36..aa0ac6a57 100644 --- a/tools/hindsight/src/verify/mod.rs +++ b/tools/hindsight/src/verify/mod.rs @@ -428,8 +428,6 @@ mod tests { token_out, amount_in: U256::from(1000), amount_out: U256::from(2000), - venue_fee_in: None, - venue_fee_out: None, min_amount_out: None, declared_quote: None, quote_timestamp: None, From 0d93cbae6b66c5476d24b105f3bb3d4cd21ea368 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 20 Aug 2026 17:33:43 -0400 Subject: [PATCH 11/13] refactor(hindsight): one declared read per solver, calldata or logs SolverDecoder::declared_swap becomes declared, taking the solver frame's calldata and the receipt's logs and returning either the terms to anchor or a trade the solver stated outright. CoW moves onto the trait as an ordinary solver, so the orchestrator loses its bespoke settlement branch. Only the decoder label changes on the 16-block sample: CoW's records read solver-logs instead of cow-trade. Every amount is identical. --- tools/hindsight/src/decoder/declared.rs | 108 ++++++++++++------ tools/hindsight/src/decoder/mod.rs | 18 +-- tools/hindsight/src/decoder/solvers/cow.rs | 35 ++++-- tools/hindsight/src/decoder/solvers/fly.rs | 56 ++++----- .../src/decoder/solvers/kyberswap.rs | 74 ++++++------ tools/hindsight/src/decoder/solvers/mod.rs | 36 ++++-- .../hindsight/src/decoder/solvers/paraswap.rs | 60 +++++----- tools/hindsight/src/decoder/solvers/zeroex.rs | 54 +++++---- 8 files changed, 250 insertions(+), 191 deletions(-) diff --git a/tools/hindsight/src/decoder/declared.rs b/tools/hindsight/src/decoder/declared.rs index aa16bcccb..cb985a01e 100644 --- a/tools/hindsight/src/decoder/declared.rs +++ b/tools/hindsight/src/decoder/declared.rs @@ -1,48 +1,90 @@ -//! The declared decode: the trade as the settling solver's own calldata states it. +//! 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. It reads -//! `token_in`/`token_out`/`amount_in` from the settling solver frame's `SwapIntent` and recovers -//! the settled `amount_out` as the gross amount of `token_out` received by the output recipient — -//! the one field calldata can never carry. The declared amounts are already on the solver-task -//! basis: any venue fee left the input before the solver frame, and the recipient's receipt is -//! the gross output before any output-side fee, so no venue knowledge is needed to decode. Venue -//! fees are still recorded for transparency when the entry point belongs to a known venue. +//! 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. //! -//! Declines (falling through to the netting decoders) when no solver frame or intent is found, -//! the recipient never received the token, or either guard below fails. +//! `amount_in` needs no fee adjustment either way: an input-side venue fee left before the solver +//! frame, so the frame's own figure is already the amount that entered the swap. No venue +//! knowledge is needed to decode. -use alloy::{primitives::Address, rpc::types::trace::geth::CallFrame}; +use alloy::{ + primitives::Address, + rpc::types::{trace::geth::CallFrame, Log}, +}; use crate::decoder::{ netting::TraderFlow, registry::Registry, - solvers::{self, SwapIntent}, + solvers::{self, Declaration, SwapIntent}, trace, transfer_ledger::{NetSwap, TransferLedger}, }; -/// Decode a transaction from the settling solver frame's own declaration, returning the flow and -/// the parsed intent (whose declared terms land on the record). -/// -/// The output recipient is the one the solver's calldata declares; a solver whose calldata -/// carries none delivers to the caller, so the transaction sender is the fallback anchor. +/// Decode a transaction from the settling solver's own declaration: the decoder label recorded on +/// the trade, the flow, and the parsed terms when the read was a calldata one (their columns land +/// on the record). /// -/// Two guards protect against the recipient-receipt query mis-attributing a multi-order -/// transaction's output: 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. +/// A log-stated trade is tried first, across every registered solver that emitted a log here: it +/// is complete, so nothing has to be recovered from the ledger. Otherwise the settling solver's +/// frame is found and its calldata read. pub(crate) fn declared_flow( root: &CallFrame, registry: &Registry, + logs: &[Log], + transfer_ledger: &TransferLedger, + sender: Address, + entry_point: Address, +) -> Option<(&'static str, TraderFlow, Option)> { + if let Some(flow) = settled_from_logs(logs, registry) { + return Some(("solver-logs", flow, None)); + } + // A batch settlement whose log read declined (a multi-order batch) must not fall through to + // calldata: its inner router frames are order plumbing, not one trade. + if registry.is_batch_settler(entry_point) { + return None; + } + let (flow, intent) = terms_from_calldata(root, registry, logs, transfer_ledger, sender)?; + Some(("solver-calldata", flow, Some(intent))) +} + +/// The trade a solver stated in its own logs, from whichever registered solver emitted one. +fn settled_from_logs(logs: &[Log], registry: &Registry) -> Option { + logs.iter() + .filter_map(|log| registry.solver(log.address())) + .find_map(|solver| match solver.decoder.declared(&[], logs, None) { + Some(Declaration::Settled(flow)) => Some(flow), + Some(Declaration::Terms(_)) | None => None, + }) +} + +/// The settling solver frame's calldata terms, with `amount_out` recovered from the recipient the +/// same calldata declares (a solver that declares none delivers to the caller, so the transaction +/// sender is the fallback anchor). +/// +/// Two guards protect against the recipient-receipt query picking up a multi-order transaction's +/// output: 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. +fn terms_from_calldata( + root: &CallFrame, + registry: &Registry, + logs: &[Log], transfer_ledger: &TransferLedger, sender: Address, ) -> Option<(TraderFlow, SwapIntent)> { let solver_frame = trace::find_solver_frame(root, registry)?; let solver = registry.solver(solver_frame.to?)?; - let intent = solver + let intent = match solver .decoder - .declared_swap(&solver_frame.input, None)?; + .declared(&solver_frame.input, logs, None)? + { + Declaration::Terms(intent) => intent, + // A solver that states its trade in logs already had its chance above, and its calldata + // is not the place to read it from. + Declaration::Settled(_) => return None, + }; let recipient = intent .output_recipient .unwrap_or(sender); @@ -117,7 +159,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; let ledger = TransferLedger::from_transaction(&logs, &native); - let (flow, intent) = declared_flow(&root, ®istry, &ledger, sender).unwrap(); + let (flow, intent) = terms_from_calldata(&root, ®istry, &[], &ledger, sender).unwrap(); assert_eq!(flow.tracked, sender); assert_eq!(flow.swap.token_in, TOKEN_IN); assert_eq!(flow.swap.token_out, Address::ZERO); @@ -136,7 +178,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT - 1))]; let ledger = TransferLedger::from_transaction(&[], &native); - assert!(declared_flow(&root, ®istry, &ledger, sender).is_none()); + assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -146,7 +188,7 @@ mod tests { let root = root_with_solver_frame(sender, ROUTER, FLY); let ledger = TransferLedger::from_transaction(&[], &[]); - assert!(declared_flow(&root, ®istry, &ledger, sender).is_none()); + assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -157,7 +199,7 @@ mod tests { 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).is_none()); + assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -171,7 +213,7 @@ mod tests { 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).is_none()); + assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -186,7 +228,7 @@ mod tests { let native = vec![(addr(50), ROUTER, implausible)]; let ledger = TransferLedger::from_transaction(&[], &native); - assert!(declared_flow(&root, ®istry, &ledger, sender).is_none()); + assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -201,7 +243,7 @@ mod tests { 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(); + let (flow, _) = terms_from_calldata(&root, ®istry, &[], &ledger, sender).unwrap(); assert_eq!(flow.tracked, sender); assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); } @@ -220,7 +262,7 @@ mod tests { 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(); + let (flow, _) = terms_from_calldata(&root, ®istry, &[], &ledger, sender).unwrap(); assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); } @@ -238,7 +280,7 @@ mod tests { 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(); + let (flow, _) = terms_from_calldata(&root, ®istry, &[], &ledger, sender).unwrap(); assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); assert_eq!(flow.swap.amount_out, U256::from(MIN_AMOUNT_OUT + 1_000)); } diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index f708108d4..05192a01d 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -256,20 +256,10 @@ impl Decoder

{ collect_native_transfers(root, &mut native); let transfer_ledger = TransferLedger::from_transaction(logs, &native); - // The declared decode runs first: the settlement's own data is the trusted reading. - // A batch settler's Trade log wins over the calldata path wherever it appears — the - // settlement can be entered through another contract, and a batch's inner router frames - // are order plumbing, not the trade (which is also why the calldata path never runs on a - // batch-settler entry). Netting is the fallback, and its records are marked. - let declared = solvers::cow::settlement_trade(logs, registry) - .map(|flow| ("cow-trade", flow, None::)) - .or_else(|| { - if registry.is_batch_settler(entry_point) { - return None; - } - declared::declared_flow(root, registry, &transfer_ledger, sender) - .map(|(flow, intent)| ("solver-calldata", flow, Some(intent))) - }); + // The declared decode runs first: the settling solver's own data is the trusted reading. + // Netting is the fallback, and its records are marked. + let declared = + declared::declared_flow(root, registry, logs, &transfer_ledger, sender, entry_point); let (decoder, mut flow, intent, amounts_declared) = if let Some((decoder, flow, intent)) = declared { (decoder, flow, intent, true) diff --git a/tools/hindsight/src/decoder/solvers/cow.rs b/tools/hindsight/src/decoder/solvers/cow.rs index e4bd5ff08..cbf794d41 100644 --- a/tools/hindsight/src/decoder/solvers/cow.rs +++ b/tools/hindsight/src/decoder/solvers/cow.rs @@ -11,7 +11,7 @@ //! like-for-like — modern `CoW` records a zero on-chain fee (it is priced into the order). use alloy::{ - primitives::{address, Address, B256}, + primitives::{address, Address, B256, U256}, rpc::types::Log, sol, sol_types::{SolCall, SolEvent}, @@ -20,6 +20,7 @@ use alloy::{ use crate::decoder::{ netting::TraderFlow, registry::Registry, + solvers::{Declaration, SolverDecoder}, transfer_ledger::{to_primitive_log, NetSwap}, }; @@ -82,13 +83,29 @@ pub(crate) fn venue_tag(registry: &Registry, entry_point: Address, input: &[u8]) /// `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"); -/// The single settled order's trade, read from the `GPv2` `Trade` event. `None` when no batch -/// settler emitted one, or the batch settles more than one order (left to the netting fallback). -pub(crate) fn settlement_trade(logs: &[Log], registry: &Registry) -> Option { - let mut trades = logs.iter().filter(|log| { - registry.is_batch_settler(log.address()) && - log.topics().first() == Some(&Trade::SIGNATURE_HASH) - }); +/// `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], + _amount_in_hint: Option, + ) -> Option { + settlement_trade(logs).map(Declaration::Settled) + } +} + +/// 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; @@ -157,7 +174,7 @@ mod tests { } fn decode(logs: &[Log]) -> Option { - settlement_trade(logs, &Registry::ethereum()) + settlement_trade(logs) } #[test] diff --git a/tools/hindsight/src/decoder/solvers/fly.rs b/tools/hindsight/src/decoder/solvers/fly.rs index d83a4b44c..17634d9e0 100644 --- a/tools/hindsight/src/decoder/solvers/fly.rs +++ b/tools/hindsight/src/decoder/solvers/fly.rs @@ -9,9 +9,12 @@ //! 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::{SolverDecoder, SwapIntent}; +use crate::decoder::solvers::{Declaration, SolverDecoder, SwapIntent}; /// Selectors sharing `LibRouter`'s packed layout (`swapWithBackendSignature`, /// `swapWithMagpieSignature`, `swapWithUserSignature`, `swapWithoutSignature`, `swap`). @@ -97,7 +100,12 @@ impl SolverDecoder for Fly { /// 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 declared_swap(&self, input: &[u8], _amount_in_hint: Option) -> Option { + fn declared( + &self, + input: &[u8], + _logs: &[Log], + _amount_in_hint: Option, + ) -> Option { let data = parse(input)?; if data.amount_in.is_zero() || data.amount_out_min.is_zero() { return None; @@ -108,16 +116,24 @@ impl SolverDecoder for Fly { let intent = SwapIntent::new(data.from_asset, data.to_asset, data.amount_in, data.amount_out_min) .with_recipient(data.to_address); - Some(if data.expected_amount_out.is_zero() { + Some(Declaration::Terms(if data.expected_amount_out.is_zero() { intent } else { intent.with_quote(data.expected_amount_out, None) - }) + })) } } #[cfg(test)] mod tests { + /// The terms this solver reads from `input`, for tests that only care about the calldata path. + fn terms(input: &[u8], hint: Option) -> Option { + match Fly.declared(input, &[], hint)? { + Declaration::Terms(intent) => Some(intent), + Declaration::Settled(_) => None, + } + } + use alloy::primitives::address; use super::*; @@ -132,9 +148,7 @@ mod tests { #[test] fn test_real_fixture_declared_swap() { - let intent = Fly - .declared_swap(&real_input(), None) - .unwrap(); + let intent = terms(&real_input(), None).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)); @@ -145,9 +159,7 @@ mod tests { #[test] fn test_real_fixture_output_recipient() { // Relay's own router — the delivery address, not the trader (see `parse`). - let intent = Fly - .declared_swap(&real_input(), None) - .unwrap(); + let intent = terms(&real_input(), None).unwrap(); assert_eq!( intent.output_recipient, Some(address!("0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f")) @@ -158,27 +170,21 @@ mod tests { fn test_wrong_selector() { let mut input = real_input(); input[0] = 0xff; - assert!(Fly - .declared_swap(&input, None) - .is_none()); + assert!(terms(&input, None).is_none()); } #[test] fn test_truncated_input() { let full = real_input(); // Cut before the fixed-offset fields are readable at all. - assert!(Fly - .declared_swap(&full[..100], None) - .is_none()); + assert!(terms(&full[..100], None).is_none()); // Cut inside the packed-header pointer's target word. - assert!(Fly - .declared_swap(&full[..300], None) - .is_none()); + assert!(terms(&full[..300], None).is_none()); } #[test] fn test_empty_input() { - assert!(Fly.declared_swap(&[], None).is_none()); + assert!(terms(&[], None).is_none()); } #[test] @@ -186,9 +192,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 - .declared_swap(&input, None) - .is_none()); + assert!(terms(&input, None).is_none()); } #[test] @@ -201,8 +205,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 - .declared_swap(&input, None) - .is_none()); + assert!(terms(&input, None).is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/kyberswap.rs b/tools/hindsight/src/decoder/solvers/kyberswap.rs index 1d64813bc..7803ad24a 100644 --- a/tools/hindsight/src/decoder/solvers/kyberswap.rs +++ b/tools/hindsight/src/decoder/solvers/kyberswap.rs @@ -8,11 +8,12 @@ use alloy::{ primitives::{Address, U256}, + rpc::types::Log, sol, sol_types::SolCall, }; -use crate::decoder::solvers::{SolverDecoder, SwapIntent}; +use crate::decoder::solvers::{Declaration, SolverDecoder, SwapIntent}; /// `KyberSwap` represents native ETH with this sentinel address rather than the zero address — /// hindsight's convention — so it is normalized on the way out. @@ -98,7 +99,12 @@ impl SolverDecoder 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 declared_swap(&self, input: &[u8], _amount_in_hint: Option) -> Option { + fn declared( + &self, + input: &[u8], + _logs: &[Log], + _amount_in_hint: Option, + ) -> Option { let call = swapCall::abi_decode(input).ok()?; let desc = call.execution.desc; if desc.amount.is_zero() || desc.minReturnAmount.is_zero() { @@ -113,15 +119,23 @@ impl SolverDecoder for Kyberswap { desc.minReturnAmount, ) .with_recipient(desc.dstReceiver); - Some(match declared_quote(input) { + Some(Declaration::Terms(match declared_quote(input) { Some((amount_out, timestamp)) => intent.with_quote(amount_out, timestamp), None => intent, - }) + })) } } #[cfg(test)] mod tests { + /// The terms this solver reads from `input`, for tests that only care about the calldata path. + fn terms(input: &[u8], hint: Option) -> Option { + match Kyberswap.declared(input, &[], hint)? { + Declaration::Terms(intent) => Some(intent), + Declaration::Settled(_) => None, + } + } + use super::*; /// The real clientData blob of tx 0xf25ceafd… (the audited Relay+KyberSwap trade). @@ -197,9 +211,7 @@ mod tests { fn test_declared_swap_round_trip() { let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); - let intent = Kyberswap - .declared_swap(&swap_calldata(src, dst, 1_000_000, 990_000, ""), None) - .unwrap(); + let intent = terms(&swap_calldata(src, dst, 1_000_000, 990_000, ""), None).unwrap(); assert_eq!(intent.token_in, src); assert_eq!(intent.token_out, dst); assert_eq!(intent.amount_in, U256::from(1_000_000u64)); @@ -213,9 +225,7 @@ mod tests { fn test_output_recipient_round_trip() { let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); - let intent = Kyberswap - .declared_swap(&swap_calldata(src, dst, 1_000_000, 990_000, ""), None) - .unwrap(); + let intent = terms(&swap_calldata(src, dst, 1_000_000, 990_000, ""), None).unwrap(); assert_eq!(intent.output_recipient, Some(Address::repeat_byte(0x77))); } @@ -223,9 +233,7 @@ mod tests { fn test_declared_swap_with_declared_quote() { let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); - let intent = Kyberswap - .declared_swap(&swap_calldata(src, dst, 1_000_000, 990_000, BLOB), None) - .unwrap(); + let intent = terms(&swap_calldata(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)); assert_eq!(intent.timestamp, Some(1_783_421_726)); @@ -237,24 +245,20 @@ mod tests { // the quote is just absent. let src = Address::repeat_byte(0x11); let dst = Address::repeat_byte(0x22); - let intent = Kyberswap - .declared_swap( - &swap_calldata(src, dst, 1_000_000, 990_000, "{\"Source\":\"relay\"}"), - None, - ) - .unwrap(); + let intent = + terms(&swap_calldata(src, dst, 1_000_000, 990_000, "{\"Source\":\"relay\"}"), None) + .unwrap(); assert_eq!(intent.quoted_amount_out(), U256::from(990_000u64)); assert_eq!(intent.timestamp, None); } #[test] fn test_declared_swap_normalizes_native_eth() { - let intent = Kyberswap - .declared_swap( - &swap_calldata(KYBERSWAP_NATIVE, Address::repeat_byte(0x22), 1_000, 900, ""), - None, - ) - .unwrap(); + let intent = terms( + &swap_calldata(KYBERSWAP_NATIVE, Address::repeat_byte(0x22), 1_000, 900, ""), + None, + ) + .unwrap(); assert_eq!(intent.token_in, Address::ZERO); assert_eq!(intent.token_out, Address::repeat_byte(0x22)); } @@ -263,26 +267,16 @@ mod tests { fn test_declared_swap_zero_amounts_rejected() { let a = Address::repeat_byte(0x11); let b = Address::repeat_byte(0x22); - assert!(Kyberswap - .declared_swap(&swap_calldata(a, b, 0, 900, ""), None) - .is_none()); - assert!(Kyberswap - .declared_swap(&swap_calldata(a, b, 1_000, 0, ""), None) - .is_none()); + assert!(terms(&swap_calldata(a, b, 0, 900, ""), None).is_none()); + assert!(terms(&swap_calldata(a, b, 1_000, 0, ""), None).is_none()); } #[test] fn test_declared_swap_garbage_input() { - assert!(Kyberswap - .declared_swap(&[], None) - .is_none()); - assert!(Kyberswap - .declared_swap(&[0xde, 0xad, 0xbe, 0xef], None) - .is_none()); + assert!(terms(&[], None).is_none()); + assert!(terms(&[0xde, 0xad, 0xbe, 0xef], None).is_none()); // A well-formed but unrelated call (KyberSwap's own clientData blob calldata) must not // decode as a `swap` execution. - assert!(Kyberswap - .declared_swap(&calldata_with(BLOB), None) - .is_none()); + assert!(terms(&calldata_with(BLOB), None).is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index 60d25558f..ed866e1a0 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -20,7 +20,7 @@ use alloy::{ rpc::types::Log, }; -use crate::decoder::{registry::Registry, veto::Veto}; +use crate::decoder::{netting::TraderFlow, registry::Registry, 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 @@ -113,20 +113,37 @@ impl SwapIntent { } } +/// What a solver's own data says about a transaction. +pub(crate) enum Declaration { + /// Swap terms read from the solver's calldata. Calldata never carries a settled amount, so + /// the caller recovers `amount_out` from the declared recipient's receipt. + Terms(SwapIntent), + /// The executed trade, amounts included — a solver that states them in its own logs. Nothing + /// is left to recover. + Settled(TraderFlow), +} + /// One solver's decoder: everything the solver's own calldata and logs can say about a trade. /// /// Every method has a default meaning "this solver's data does not carry that", so a solver only /// implements what its transactions expose; most solvers need no code at all. pub(crate) trait SolverDecoder: Send + Sync { - /// The swap terms encoded in the solver frame's own calldata — including the output - /// recipient, when the calldata declares one — for a solver whose calldata carries them - /// plainly enough to recover without netting a settled amount. Called with the solver frame's - /// input (found via `trace::find_solver_frame`), not the root transaction's: a packed calldata - /// layout (Fly) uses offsets valid only in its own frame. + /// What this solver's own data says about the transaction, or `None` when it says nothing + /// this solver can read. + /// + /// `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. /// /// `amount_in_hint` is a netted input amount, when one is known. Some extractors (`ParaSwap`) /// need it to locate fields by value rather than by ABI offset. - fn declared_swap(&self, _input: &[u8], _amount_in_hint: Option) -> Option { + fn declared( + &self, + _input: &[u8], + _logs: &[Log], + _amount_in_hint: Option, + ) -> Option { None } @@ -150,6 +167,7 @@ pub(crate) trait SolverDecoder: Send + Sync { /// 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), @@ -324,10 +342,10 @@ mod tests { input.extend_from_slice(&word.to_be_bytes::<32>()); } assert!(decoder_for("paraswap") - .declared_swap(&input, Some(amount_in)) + .declared(&input, &[], Some(amount_in)) .is_some()); assert!(decoder_for("1inch") - .declared_swap(&input, Some(amount_in)) + .declared(&input, &[], Some(amount_in)) .is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/paraswap.rs b/tools/hindsight/src/decoder/solvers/paraswap.rs index 1ee4546e6..c00082afa 100644 --- a/tools/hindsight/src/decoder/solvers/paraswap.rs +++ b/tools/hindsight/src/decoder/solvers/paraswap.rs @@ -9,9 +9,12 @@ //! 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 alloy::{ + primitives::{Address, U256}, + rpc::types::Log, +}; -use crate::decoder::solvers::{SolverDecoder, SwapIntent}; +use crate::decoder::solvers::{Declaration, SolverDecoder, SwapIntent}; /// Byte length of an ABI-encoded word. const WORD_LEN: usize = 32; @@ -51,7 +54,12 @@ impl SolverDecoder for Paraswap { /// 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 declared_swap(&self, input: &[u8], amount_in_hint: Option) -> Option { + fn declared( + &self, + input: &[u8], + _logs: &[Log], + amount_in_hint: Option, + ) -> Option { let amount_in = amount_in_hint.filter(|hint| !hint.is_zero())?; if input.len() < 4 { return None; @@ -83,7 +91,7 @@ impl SolverDecoder for Paraswap { amount_in, to_amount, ); - return Some(intent.with_quote(quoted, None)); + return Some(Declaration::Terms(intent.with_quote(quoted, None))); } None } @@ -91,6 +99,14 @@ impl SolverDecoder for Paraswap { #[cfg(test)] mod tests { + /// The terms this solver reads from `input`, for tests that only care about the calldata path. + fn terms(input: &[u8], hint: Option) -> Option { + match Paraswap.declared(input, &[], hint)? { + Declaration::Terms(intent) => Some(intent), + Declaration::Settled(_) => None, + } + } + use super::*; /// Word-aligned Augustus-style calldata: selector, then 32-byte words. @@ -118,9 +134,7 @@ mod tests { U256::from(171_602_266u64), U256::ZERO, // metadata ]; - let intent = Paraswap - .declared_swap(&calldata(&words), Some(amount_in)) - .unwrap(); + let intent = terms(&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); @@ -138,15 +152,9 @@ mod tests { U256::from(171_430_663u64), U256::from(171_602_266u64), ]; - assert!(Paraswap - .declared_swap(&calldata(&words), Some(U256::from(999u64))) - .is_none()); - assert!(Paraswap - .declared_swap(&[], Some(U256::from(1u64))) - .is_none()); - assert!(Paraswap - .declared_swap(&calldata(&words), Some(U256::ZERO)) - .is_none()); + assert!(terms(&calldata(&words), Some(U256::from(999u64))).is_none()); + assert!(terms(&[], Some(U256::from(1u64))).is_none()); + assert!(terms(&calldata(&words), Some(U256::ZERO)).is_none()); } #[test] @@ -160,9 +168,7 @@ mod tests { U256::from(171_430_663u64), U256::from(171_602_266u64), ]; - assert!(Paraswap - .declared_swap(&calldata(&words), None) - .is_none()); + assert!(terms(&calldata(&words), None).is_none()); } #[test] @@ -177,9 +183,7 @@ mod tests { U256::from(990_000u64), U256::from(400_000u64), ]; - assert!(Paraswap - .declared_swap(&calldata(&below), Some(amount_in)) - .is_none()); + assert!(terms(&calldata(&below), Some(amount_in)).is_none()); let far_above = [ U256::from(0x1111u64), U256::from(0x2222u64), @@ -187,9 +191,7 @@ mod tests { U256::from(990_000u64), U256::from(10_000_000u64), ]; - assert!(Paraswap - .declared_swap(&calldata(&far_above), Some(amount_in)) - .is_none()); + assert!(terms(&calldata(&far_above), Some(amount_in)).is_none()); } #[test] @@ -205,9 +207,7 @@ mod tests { U256::from(990_000u64), U256::from(995_000u64), ]; - assert!(Paraswap - .declared_swap(&calldata(&words), Some(amount_in)) - .is_none()); + assert!(terms(&calldata(&words), Some(amount_in)).is_none()); } #[test] @@ -216,8 +216,6 @@ mod tests { // 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 - .declared_swap(&calldata(&words), Some(amount_in)) - .is_none()); + assert!(terms(&calldata(&words), Some(amount_in)).is_none()); } } diff --git a/tools/hindsight/src/decoder/solvers/zeroex.rs b/tools/hindsight/src/decoder/solvers/zeroex.rs index 9283ae3e0..01c6012a7 100644 --- a/tools/hindsight/src/decoder/solvers/zeroex.rs +++ b/tools/hindsight/src/decoder/solvers/zeroex.rs @@ -20,11 +20,12 @@ use alloy::{ primitives::{Address, U256}, + rpc::types::Log, sol, sol_types::SolCall, }; -use crate::decoder::solvers::{SolverDecoder, SwapIntent}; +use crate::decoder::solvers::{Declaration, SolverDecoder, SwapIntent}; sol! { /// `IAllowanceHolder.exec` — Relay's 0x flow always enters through this wrapper before @@ -109,7 +110,12 @@ impl SolverDecoder for ZeroEx { /// 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 declared_swap(&self, input: &[u8], _amount_in_hint: Option) -> Option { + fn declared( + &self, + input: &[u8], + _logs: &[Log], + _amount_in_hint: Option, + ) -> Option { let call = execCall::abi_decode(input).ok()?; if call.amount.is_zero() { return None; @@ -124,15 +130,23 @@ impl SolverDecoder for ZeroEx { terms.min_amount_out, ) .with_recipient(terms.recipient); - Some(match terms.declared_quote { + Some(Declaration::Terms(match terms.declared_quote { Some(quote) => intent.with_quote(quote, None), None => intent, - }) + })) } } #[cfg(test)] mod tests { + /// The terms this solver reads from `input`, for tests that only care about the calldata path. + fn terms(input: &[u8], hint: Option) -> Option { + match ZeroEx.declared(input, &[], hint)? { + Declaration::Terms(intent) => Some(intent), + Declaration::Settled(_) => None, + } + } + use alloy::primitives::address; use super::*; @@ -160,9 +174,7 @@ mod tests { #[test] fn test_real_settled_declared_swap() { - let intent = ZeroEx - .declared_swap(&settled_input(), None) - .unwrap(); + let intent = terms(&settled_input(), None).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)); @@ -172,9 +184,7 @@ mod tests { #[test] fn test_real_settled_output_recipient() { - let intent = ZeroEx - .declared_swap(&settled_input(), None) - .unwrap(); + let intent = terms(&settled_input(), None).unwrap(); assert_eq!(intent.output_recipient, Some(RELAY_ROUTER)); } @@ -182,9 +192,7 @@ mod tests { 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 - .declared_swap(&reverted_input(), None) - .unwrap(); + let intent = terms(&reverted_input(), None).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)); @@ -194,9 +202,7 @@ mod tests { #[test] fn test_real_reverted_output_recipient() { - let intent = ZeroEx - .declared_swap(&reverted_input(), None) - .unwrap(); + let intent = terms(&reverted_input(), None).unwrap(); assert_eq!(intent.output_recipient, Some(RELAY_ROUTER)); } @@ -221,16 +227,12 @@ mod tests { zidAndAffiliate: alloy::primitives::FixedBytes::default(), }; let input = executeCall::abi_encode(&call); - assert!(ZeroEx - .declared_swap(&input, None) - .is_none()); + assert!(terms(&input, None).is_none()); } #[test] fn test_garbage_input_declines() { - assert!(ZeroEx - .declared_swap(&[0xde, 0xad, 0xbe, 0xef], None) - .is_none()); + assert!(terms(&[0xde, 0xad, 0xbe, 0xef], None).is_none()); } #[test] @@ -244,9 +246,7 @@ mod tests { data: alloy::primitives::Bytes::new(), }; let input = execCall::abi_encode(&call); - assert!(ZeroEx - .declared_swap(&input, None) - .is_none()); + assert!(terms(&input, None).is_none()); } #[test] @@ -270,9 +270,7 @@ mod tests { data: executeCall::abi_encode(&execute_call).into(), }; let input = execCall::abi_encode(&call); - let intent = ZeroEx - .declared_swap(&input, None) - .unwrap(); + let intent = terms(&input, None).unwrap(); assert_eq!(intent.min_amount_out, U256::ZERO); } From 0c07074f2a988519ec579df0594d83a8896e1023 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 20 Aug 2026 17:47:27 -0400 Subject: [PATCH 12/13] feat(hindsight): read okx trades from its OrderRecord log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OKX states each settled order in an OrderRecord event: both tokens, the trader, the amount that entered the swap and the amount returned. That is a complete trade, so it needs no ledger recovery, and it survives the router's several entry functions because none of them changes the event. fromAmount is the amount that reached the pools, after OKX's own commission — verified on four live trades, where every returnAmount matched the settled record exactly while fromAmount sat 0 to 85 bps below the trader's gross spend. On the 16-block sample this moves 19 records from netted to declared and adds one transaction that previously decoded as nothing. The declared read is also now scoped to the settling frame's own solver, so a record's amounts and its solver label always come from the same solver. --- tools/hindsight/src/decoder/declared.rs | 89 ++++------ tools/hindsight/src/decoder/mod.rs | 3 +- tools/hindsight/src/decoder/solvers/mod.rs | 2 + tools/hindsight/src/decoder/solvers/okx.rs | 193 +++++++++++++++++++++ 4 files changed, 229 insertions(+), 58 deletions(-) create mode 100644 tools/hindsight/src/decoder/solvers/okx.rs diff --git a/tools/hindsight/src/decoder/declared.rs b/tools/hindsight/src/decoder/declared.rs index cb985a01e..b1f3a2a69 100644 --- a/tools/hindsight/src/decoder/declared.rs +++ b/tools/hindsight/src/decoder/declared.rs @@ -26,69 +26,47 @@ use crate::decoder::{ /// the trade, the flow, and the parsed terms when the read was a calldata one (their columns land /// on the record). /// -/// A log-stated trade is tried first, across every registered solver that emitted a log here: it -/// is complete, so nothing has to be recovered from the ledger. Otherwise the settling solver's -/// frame is found and its calldata read. +/// 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. pub(crate) fn declared_flow( root: &CallFrame, registry: &Registry, logs: &[Log], transfer_ledger: &TransferLedger, sender: Address, - entry_point: Address, ) -> Option<(&'static str, TraderFlow, Option)> { - if let Some(flow) = settled_from_logs(logs, registry) { - return Some(("solver-logs", flow, None)); - } - // A batch settlement whose log read declined (a multi-order batch) must not fall through to - // calldata: its inner router frames are order plumbing, not one trade. - if registry.is_batch_settler(entry_point) { - return None; + let solver_frame = trace::find_solver_frame(root, registry)?; + let solver = registry.solver(solver_frame.to?)?; + match solver + .decoder + .declared(&solver_frame.input, logs, None)? + { + // Stated outright in the solver's own logs: amounts included, nothing to recover. + Declaration::Settled(flow) => Some(("solver-logs", flow, None)), + Declaration::Terms(intent) => { + let flow = anchor_output(intent, transfer_ledger, sender)?; + Some(("solver-calldata", flow, Some(intent))) + } } - let (flow, intent) = terms_from_calldata(root, registry, logs, transfer_ledger, sender)?; - Some(("solver-calldata", flow, Some(intent))) -} - -/// The trade a solver stated in its own logs, from whichever registered solver emitted one. -fn settled_from_logs(logs: &[Log], registry: &Registry) -> Option { - logs.iter() - .filter_map(|log| registry.solver(log.address())) - .find_map(|solver| match solver.decoder.declared(&[], logs, None) { - Some(Declaration::Settled(flow)) => Some(flow), - Some(Declaration::Terms(_)) | None => None, - }) } -/// The settling solver frame's calldata terms, with `amount_out` recovered from the recipient the -/// same calldata declares (a solver that declares none delivers to the caller, so the transaction -/// sender is the fallback anchor). +/// Recover the settled `amount_out` for calldata terms: the gross amount the declared recipient +/// received (a solver that declares none delivers to the caller, so the transaction sender is the +/// fallback anchor). /// /// Two guards protect against the recipient-receipt query picking up a multi-order transaction's /// output: 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. -fn terms_from_calldata( - root: &CallFrame, - registry: &Registry, - logs: &[Log], +fn anchor_output( + intent: SwapIntent, transfer_ledger: &TransferLedger, sender: Address, -) -> Option<(TraderFlow, SwapIntent)> { - let solver_frame = trace::find_solver_frame(root, registry)?; - let solver = registry.solver(solver_frame.to?)?; - let intent = match solver - .decoder - .declared(&solver_frame.input, logs, None)? - { - Declaration::Terms(intent) => intent, - // A solver that states its trade in logs already had its chance above, and its calldata - // is not the place to read it from. - Declaration::Settled(_) => return None, - }; +) -> Option { let recipient = intent .output_recipient .unwrap_or(sender); - let amount_out = transfer_ledger.received_by_address(recipient, intent.token_out); if amount_out.is_zero() || amount_out < intent.min_amount_out { return None; @@ -98,8 +76,7 @@ fn terms_from_calldata( return None; } } - - let flow = TraderFlow::new( + Some(TraderFlow::new( sender, NetSwap { token_in: intent.token_in, @@ -107,8 +84,7 @@ fn terms_from_calldata( token_out: intent.token_out, amount_out, }, - ); - Some((flow, intent)) + )) } #[cfg(test)] @@ -159,7 +135,8 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; let ledger = TransferLedger::from_transaction(&logs, &native); - let (flow, intent) = terms_from_calldata(&root, ®istry, &[], &ledger, sender).unwrap(); + let (_, flow, intent) = declared_flow(&root, ®istry, &[], &ledger, sender).unwrap(); + let intent = intent.unwrap(); assert_eq!(flow.tracked, sender); assert_eq!(flow.swap.token_in, TOKEN_IN); assert_eq!(flow.swap.token_out, Address::ZERO); @@ -178,7 +155,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT - 1))]; let ledger = TransferLedger::from_transaction(&[], &native); - assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); + assert!(declared_flow(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -188,7 +165,7 @@ mod tests { let root = root_with_solver_frame(sender, ROUTER, FLY); let ledger = TransferLedger::from_transaction(&[], &[]); - assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); + assert!(declared_flow(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -199,7 +176,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; let ledger = TransferLedger::from_transaction(&[], &native); - assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); + assert!(declared_flow(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -213,7 +190,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; let ledger = TransferLedger::from_transaction(&[], &native); - assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); + assert!(declared_flow(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -228,7 +205,7 @@ mod tests { let native = vec![(addr(50), ROUTER, implausible)]; let ledger = TransferLedger::from_transaction(&[], &native); - assert!(terms_from_calldata(&root, ®istry, &[], &ledger, sender).is_none()); + assert!(declared_flow(&root, ®istry, &[], &ledger, sender).is_none()); } #[test] @@ -243,7 +220,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; let ledger = TransferLedger::from_transaction(&logs, &native); - let (flow, _) = terms_from_calldata(&root, ®istry, &[], &ledger, sender).unwrap(); + let (_, flow, _) = declared_flow(&root, ®istry, &[], &ledger, sender).unwrap(); assert_eq!(flow.tracked, sender); assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); } @@ -262,7 +239,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; let ledger = TransferLedger::from_transaction(&logs, &native); - let (flow, _) = terms_from_calldata(&root, ®istry, &[], &ledger, sender).unwrap(); + let (_, flow, _) = declared_flow(&root, ®istry, &[], &ledger, sender).unwrap(); assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); } @@ -280,7 +257,7 @@ mod tests { let native = vec![(addr(50), ROUTER, U256::from(MIN_AMOUNT_OUT + 1_000))]; let ledger = TransferLedger::from_transaction(&logs, &native); - let (flow, _) = terms_from_calldata(&root, ®istry, &[], &ledger, sender).unwrap(); + let (_, flow, _) = declared_flow(&root, ®istry, &[], &ledger, sender).unwrap(); assert_eq!(flow.swap.amount_in, U256::from(AMOUNT_IN)); assert_eq!(flow.swap.amount_out, U256::from(MIN_AMOUNT_OUT + 1_000)); } diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index 05192a01d..10f6a0c37 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -258,8 +258,7 @@ impl Decoder

{ // The declared decode runs first: the settling solver's own data is the trusted reading. // Netting is the fallback, and its records are marked. - let declared = - declared::declared_flow(root, registry, logs, &transfer_ledger, sender, entry_point); + let declared = declared::declared_flow(root, registry, logs, &transfer_ledger, sender); let (decoder, mut flow, intent, amounts_declared) = if let Some((decoder, flow, intent)) = declared { (decoder, flow, intent, true) diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index ed866e1a0..731c6707e 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod cow; pub(crate) mod fly; pub(crate) mod kyberswap; pub(crate) mod lifi; +pub(crate) mod okx; pub(crate) mod paraswap; pub(crate) mod zeroex; @@ -171,6 +172,7 @@ const IMPLEMENTATIONS: &[(&str, &'static dyn SolverDecoder)] = &[ ("fly", &fly::Fly), ("kyberswap", &kyberswap::Kyberswap), ("lifi", &lifi::Lifi), + ("okx", &okx::Okx), ("paraswap", ¶swap::Paraswap), ("0x", &zeroex::ZeroEx), ]; diff --git a/tools/hindsight/src/decoder/solvers/okx.rs b/tools/hindsight/src/decoder/solvers/okx.rs new file mode 100644 index 000000000..e6f135b61 --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/okx.rs @@ -0,0 +1,193 @@ +//! 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::{ + primitives::{address, Address, U256}, + rpc::types::Log, + sol, + sol_types::SolEvent, +}; + +use crate::decoder::{ + netting::TraderFlow, + solvers::{Declaration, SolverDecoder}, + transfer_ledger::{to_primitive_log, NetSwap}, +}; + +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 + ); +} + +/// OKX's sentinel for native ETH, normalized to the zero address like every other flow. +const OKX_NATIVE_ETH: Address = address!("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"); + +fn normalize_native(token: Address) -> Address { + if token == OKX_NATIVE_ETH { + Address::ZERO + } else { + token + } +} + +/// 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], + _amount_in_hint: Option, + ) -> Option { + let mut records = logs + .iter() + .filter(|log| log.topics().first() == Some(&OrderRecord::SIGNATURE_HASH)); + let first = records.next()?; + if records.next().is_some() { + return None; + } + let record = OrderRecord::decode_log(&to_primitive_log(first)).ok()?; + if record.fromAmount.is_zero() || record.returnAmount.is_zero() { + return None; + } + Some(Declaration::Settled(TraderFlow::new( + record.sender, + NetSwap { + token_in: normalize_native(record.fromToken), + amount_in: record.fromAmount, + token_out: normalize_native(record.toToken), + amount_out: record.returnAmount, + }, + ))) + } +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{b256, Log as PrimitiveLog}; + + use super::*; + use crate::decoder::test_utils::{addr, make_transfer_log, swap}; + + /// 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 { + match Okx.declared(&[], logs, None)? { + Declaration::Settled(flow) => Some(flow), + Declaration::Terms(_) => None, + } + } + + #[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, trader); + assert_eq!(flow.swap.token_in, usdt); + assert_eq!(flow.swap.amount_in, U256::from(35_551_438u64)); + assert_eq!(flow.swap.token_out, token_out); + assert_eq!(flow.swap.amount_out, 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( + OKX_NATIVE_ETH, + usdc, + addr(1), + 30_000_000_000_000_000, + 56_277_456, + )]) + .unwrap(); + assert_eq!(flow.swap, swap(Address::ZERO, 30_000_000_000_000_000, usdc, 56_277_456)); + } + + #[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()); + } +} From 8fdb474472026e571e1018bf38ebd251f33501be Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 20 Aug 2026 17:56:36 -0400 Subject: [PATCH 13/13] feat(hindsight): read 1inch market swaps from the v6 swap calldata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v6 router's swap entry carries a SwapDescription with both tokens, the input amount, the on-chain floor and the recipient. Verified against a live trade: srcToken and amount matched the settled record exactly, dstReceiver was the trader, and the settled output sat 101 bps above minReturnAmount. Two other entries in live traffic are declined rather than guessed. unoswap packs its pools into bitmasks with no token pair in the calldata. fillOrderArgs is a limit-order fill, where the amounts are a price the maker signed off-chain and a partial fill settles only part of it — reading that as a market swap would compare a signed limit price against a spot quote. On the 16-block sample 7 of the 21 1inch records move to declared. One gains 88 bps of output, because the calldata names MetaMask's router as recipient and so recovers the gross output before MetaMask's fee; another loses 40 percent of its input, which netting had been overstating. --- .../solvers/fixtures/oneinch_input.txt | 1 + tools/hindsight/src/decoder/solvers/mod.rs | 2 + .../hindsight/src/decoder/solvers/oneinch.rs | 177 ++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 tools/hindsight/src/decoder/solvers/fixtures/oneinch_input.txt create mode 100644 tools/hindsight/src/decoder/solvers/oneinch.rs 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/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs index 731c6707e..3c87814e2 100644 --- a/tools/hindsight/src/decoder/solvers/mod.rs +++ b/tools/hindsight/src/decoder/solvers/mod.rs @@ -13,6 +13,7 @@ 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 zeroex; @@ -172,6 +173,7 @@ const IMPLEMENTATIONS: &[(&str, &'static dyn SolverDecoder)] = &[ ("fly", &fly::Fly), ("kyberswap", &kyberswap::Kyberswap), ("lifi", &lifi::Lifi), + ("1inch", &oneinch::OneInch), ("okx", &okx::Okx), ("paraswap", ¶swap::Paraswap), ("0x", &zeroex::ZeroEx), diff --git a/tools/hindsight/src/decoder/solvers/oneinch.rs b/tools/hindsight/src/decoder/solvers/oneinch.rs new file mode 100644 index 000000000..e1373fa9a --- /dev/null +++ b/tools/hindsight/src/decoder/solvers/oneinch.rs @@ -0,0 +1,177 @@ +//! 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::{ + primitives::{address, Address, U256}, + rpc::types::Log, + sol, + sol_types::SolCall, +}; + +use crate::decoder::solvers::{Declaration, SolverDecoder, SwapIntent}; + +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); +} + +/// 1inch's sentinel for native ETH, normalized to the zero address like every other flow. +const ONEINCH_NATIVE_ETH: Address = address!("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"); + +fn normalize_native(token: Address) -> Address { + if token == ONEINCH_NATIVE_ETH { + Address::ZERO + } else { + token + } +} + +/// 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], + _amount_in_hint: Option, + ) -> Option { + let call = swapCall::abi_decode(input).ok()?; + if call.desc.amount.is_zero() { + return None; + } + let intent = SwapIntent::new( + normalize_native(call.desc.srcToken), + normalize_native(call.desc.dstToken), + call.desc.amount, + call.desc.minReturnAmount, + ) + .with_recipient(call.desc.dstReceiver); + Some(Declaration::Terms(intent)) + } +} + +#[cfg(test)] +mod tests { + use alloy::primitives::Bytes; + + 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 { + match OneInch.declared(input, &[], None)? { + Declaration::Terms(intent) => Some(intent), + Declaration::Settled(_) => None, + } + } + + #[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, U256::from(AMOUNT_IN)); + assert_eq!(intent.min_amount_out, 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()); + } +}

settled txvenuesolvernet bpssavings
bpssavings