diff --git a/tools/hindsight/CLAUDE.md b/tools/hindsight/CLAUDE.md index 20f0bcac..3b5e3918 100644 --- a/tools/hindsight/CLAUDE.md +++ b/tools/hindsight/CLAUDE.md @@ -80,6 +80,14 @@ Match → trace → decode → veto → record. Three address tiers: **venue** (order-flow owner, `tx.to`), **solver** (router that settled the trade), **liquidity venues** (pools inside traces — not modeled here). +Decode is a pure function of receipt, trace, and calldata — `DecodeContext` carries no RPC +provider, so no `TradeDecoder` issues one. The one fact that used to require a lookup +(`IntentNetting`'s contract-or-EOA check on a candidate address) is instead prefetched by the +decoder driver (`Decoder::prefetch_contract_flags`) before per-tx decode runs, scoped to exactly +the candidates `intents::netting::intent_candidates` would enumerate for that block's intent-role +transactions — not every address in every ledger, since intent fills are a small fraction of +matched transactions — and joined into the same cross-block `code_cache` the driver already owned. + ### The address book (`registry/.toml`) All chain- and protocol-specific data lives in a per-chain TOML, embedded for the six chains listed diff --git a/tools/hindsight/README.md b/tools/hindsight/README.md index b10de519..f5a99285 100644 --- a/tools/hindsight/README.md +++ b/tools/hindsight/README.md @@ -75,10 +75,10 @@ read the value movements, the calldata, the protocol's event logs, some combinat a source we have not needed yet; netting is simply the one that exists today. ```rust -trait TradeDecoder

{ +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; + async fn decode(&self, ctx: &mut DecodeContext) -> Option; } // decode.rs — the matched entity selects its decoders diff --git a/tools/hindsight/src/decoder/decode.rs b/tools/hindsight/src/decoder/decode.rs index cd0aeb82..40cf1ddb 100644 --- a/tools/hindsight/src/decoder/decode.rs +++ b/tools/hindsight/src/decoder/decode.rs @@ -17,7 +17,6 @@ use std::collections::HashMap; use alloy::{ network::AnyTransactionReceipt, primitives::{Address, U256}, - providers::Provider, rpc::types::trace::geth::CallFrame, }; use async_trait::async_trait; @@ -31,15 +30,16 @@ use crate::decoder::{ }; /// 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). +/// cannot. Async for trait-object ergonomics (`Box`); decoding itself issues no +/// RPC — every fact it needs, including the one that used to require a lookup (is a candidate +/// address a contract or an EOA), arrives pre-gathered in the `DecodeContext`. #[async_trait] -pub(crate) trait TradeDecoder: Send + Sync { +pub(crate) trait TradeDecoder: Send + Sync { /// Label recorded on the trades this decoder produced, so the JSONL records say which decoder /// carried each trade (deliberately not a metric label). fn name(&self) -> &'static str; - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option; + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option; } /// Whose flow a matched transaction carries — the axis that selects the decoders. @@ -57,7 +57,11 @@ 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 { + /// + /// Pure and RPC-free, so the decoder driver can also call it ahead of decode — to know which + /// transactions will need `IntentNetting`'s contract-flag prefetch (see + /// `intents::netting::intent_candidates`) — without duplicating this logic. + pub(crate) fn classify(entry_point: Address, registry: &'a Registry) -> Self { if let Some(name) = registry.venue_name(entry_point) { return TraderRole::Venue(name); } @@ -76,7 +80,7 @@ impl<'a> TraderRole<'a> { /// 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>> { +fn decoders_for(role: TraderRole<'_>) -> Vec> { match role { TraderRole::Sender => vec![Box::new(SenderNetting)], TraderRole::Intent => intents::decoders_for(), @@ -86,9 +90,7 @@ fn decoders_for(role: TraderRole<'_>) -> Vec( - ctx: &mut DecodeContext<'_, P>, -) -> Option<(&'static str, TraderFlow)> { +pub(crate) async fn recover(ctx: &mut DecodeContext<'_>) -> Option<(&'static str, TraderFlow)> { let role = TraderRole::classify(ctx.entry_point, ctx.registry); if let TraderRole::Venue(name) = role { let registry = ctx.registry; @@ -98,9 +100,9 @@ pub(crate) async fn recover( } /// 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>, +async fn try_decoders( + decoders: Vec>, + ctx: &mut DecodeContext<'_>, ) -> Option<(&'static str, TraderFlow)> { for decoder in decoders { if let Some(flow) = decoder.decode(ctx).await { @@ -114,13 +116,16 @@ async fn try_decoders( /// /// 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, +/// ledger all arrive here. A decoder that starts needing another input extends this struct. Decode +/// is a pure function of this context — nothing here is fetched lazily, so no decoder issues RPC +/// of its own. +pub(crate) struct DecodeContext<'a> { pub registry: &'a Registry, - /// Cross-block contract-code cache, owned by the decoder. - pub code_cache: &'a mut HashMap, + /// Contract-or-EOA facts for this block's intent-fill candidates, gathered by the decoder + /// driver before decode runs (see `Decoder::prefetch_contract_flags`) — the one fact decoding + /// used to fetch lazily via RPC (`IntentNetting`'s `eth_getCode` check). An address absent + /// from the map was never a candidate for this transaction. + pub contract_flags: &'a HashMap, /// The matched transaction's receipt (sender, logs). pub receipt: &'a AnyTransactionReceipt, /// The contract the transaction entered through (`tx.to`). @@ -216,8 +221,6 @@ mod tests { Arc, }; - use alloy::{providers::RootProvider, rpc::client::RpcClient, transports::mock::Asserter}; - use super::*; use crate::decoder::test_utils::{addr, frame, receipt, swap, tx_hash}; @@ -225,12 +228,12 @@ mod tests { struct Declines; #[async_trait] - impl TradeDecoder

for Declines { + impl TradeDecoder for Declines { fn name(&self) -> &'static str { "declines" } - async fn decode(&self, _ctx: &mut DecodeContext<'_, P>) -> Option { + async fn decode(&self, _ctx: &mut DecodeContext<'_>) -> Option { None } } @@ -239,12 +242,12 @@ mod tests { struct Wins; #[async_trait] - impl TradeDecoder

for Wins { + impl TradeDecoder for Wins { fn name(&self) -> &'static str { "wins" } - async fn decode(&self, _ctx: &mut DecodeContext<'_, P>) -> Option { + async fn decode(&self, _ctx: &mut DecodeContext<'_>) -> Option { Some(TraderFlow::without_fees(addr(1), swap(addr(10), 1, addr(11), 2))) } } @@ -253,30 +256,26 @@ mod tests { struct CountsCalls(Arc); #[async_trait] - impl TradeDecoder

for CountsCalls { + impl TradeDecoder for CountsCalls { fn name(&self) -> &'static str { "counts" } - async fn decode(&self, _ctx: &mut DecodeContext<'_, P>) -> Option { + async fn decode(&self, _ctx: &mut DecodeContext<'_>) -> 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())); + async fn try_with(decoders: Vec>) -> Option<(&'static str, TraderFlow)> { let registry = Registry::ethereum(); - let mut code_cache = HashMap::new(); + let contract_flags = HashMap::new(); let receipt = receipt(tx_hash(1), addr(1), Some(addr(2)), vec![]); let transfer_ledger = TransferLedger::from_transaction(&[], &[]); let root = frame("CALL", addr(1), addr(2), 0); let mut ctx = DecodeContext { - provider: &provider, registry: ®istry, - code_cache: &mut code_cache, + contract_flags: &contract_flags, receipt: &receipt, entry_point: addr(2), transfer_ledger: &transfer_ledger, diff --git a/tools/hindsight/src/decoder/intents/cow.rs b/tools/hindsight/src/decoder/intents/cow.rs index 3aa49410..d7c12728 100644 --- a/tools/hindsight/src/decoder/intents/cow.rs +++ b/tools/hindsight/src/decoder/intents/cow.rs @@ -13,7 +13,6 @@ use alloy::{ primitives::{address, Address, B256}, - providers::Provider, sol, sol_types::{SolCall, SolEvent}, }; @@ -83,12 +82,12 @@ const COW_NATIVE_ETH: Address = address!("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee pub(crate) struct CowSettlement; #[async_trait] -impl TradeDecoder

for CowSettlement { +impl TradeDecoder for CowSettlement { fn name(&self) -> &'static str { "cow-trade" } - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option { let mut trades = ctx.receipt.logs().iter().filter(|log| { ctx.registry .is_batch_settler(log.address()) && @@ -138,10 +137,8 @@ mod tests { use alloy::{ primitives::{address, b256, Bytes, U256}, - providers::RootProvider, - rpc::{client::RpcClient, types::Log}, + rpc::types::Log, sol_types::SolCall, - transports::mock::Asserter, }; use super::*; @@ -183,15 +180,13 @@ mod tests { 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 contract_flags = HashMap::new(); let receipt = receipt(tx_hash(1), addr(2), Some(COW_SETTLEMENT), logs); let transfer_ledger = TransferLedger::from_transaction(&[], &[]); let root = frame("CALL", addr(2), COW_SETTLEMENT, 0); let mut ctx = DecodeContext { - provider: &provider, registry: ®istry, - code_cache: &mut code_cache, + contract_flags: &contract_flags, receipt: &receipt, entry_point: COW_SETTLEMENT, transfer_ledger: &transfer_ledger, diff --git a/tools/hindsight/src/decoder/intents/mod.rs b/tools/hindsight/src/decoder/intents/mod.rs index 7e78a4d0..b362a824 100644 --- a/tools/hindsight/src/decoder/intents/mod.rs +++ b/tools/hindsight/src/decoder/intents/mod.rs @@ -8,16 +8,13 @@ pub(crate) mod cow; pub(crate) mod netting; -use alloy::{ - primitives::{Address, B256}, - providers::Provider, -}; +use alloy::primitives::{Address, B256}; 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>> { +pub(crate) fn decoders_for() -> Vec> { vec![Box::new(cow::CowSettlement), Box::new(netting::IntentNetting)] } diff --git a/tools/hindsight/src/decoder/intents/netting.rs b/tools/hindsight/src/decoder/intents/netting.rs index 09948aad..64c2e4d5 100644 --- a/tools/hindsight/src/decoder/intents/netting.rs +++ b/tools/hindsight/src/decoder/intents/netting.rs @@ -4,12 +4,16 @@ //! (`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. +//! +//! The one fact this needs beyond the transfer ledger — whether a candidate address is a +//! contract or an EOA — is prefetched by the decoder driver before decode runs (see +//! `decoder::Decoder::prefetch_contract_flags`), so `find_intent_trade` issues no RPC of its own; +//! `contract_flags` is a plain lookup into facts already gathered. use std::collections::HashMap; -use alloy::{primitives::Address, providers::Provider}; +use alloy::primitives::Address; use async_trait::async_trait; -use tracing::warn; use crate::decoder::{ decode::{DecodeContext, TradeDecoder, TraderFlow}, @@ -22,20 +26,18 @@ use crate::decoder::{ pub(crate) struct IntentNetting; #[async_trait] -impl TradeDecoder

for IntentNetting { +impl TradeDecoder for IntentNetting { fn name(&self) -> &'static str { "intent-netting" } - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option { find_intent_trade( - ctx.provider, ctx.transfer_ledger, &[ctx.entry_point, ctx.receipt.from], ctx.registry, - ctx.code_cache, + ctx.contract_flags, ) - .await } } @@ -43,11 +45,16 @@ impl TradeDecoder

for IntentNetting { /// /// 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. +/// never qualify: 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. +/// +/// Whether a candidate is a contract comes from `contract_flags`, prefetched by the decoder +/// driver for exactly this candidate set (see `intent_candidates`) before decode ran. A candidate +/// absent from the map is treated as a contract — the same conservative default the old +/// RPC-failure path used, so a prefetch gap declines rather than guesses. /// /// v0 limitations (tracked for a decode/attribution rework): /// - **One swapper per transaction.** The first clean-net EOA wins, so a batch that settles several @@ -58,15 +65,18 @@ impl TradeDecoder

for IntentNetting { /// 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, +pub(crate) fn find_intent_trade( transfer_ledger: &TransferLedger, exclude: &[Address], registry: &Registry, - code_cache: &mut HashMap, + contract_flags: &HashMap, ) -> Option { for (candidate, trade) in intent_candidates(transfer_ledger, exclude, registry) { - if !is_contract(provider, candidate, code_cache).await { + let is_contract = contract_flags + .get(&candidate) + .copied() + .unwrap_or(true); + if !is_contract { return Some(TraderFlow::without_fees(candidate, trade)); } } @@ -76,7 +86,11 @@ pub(crate) async fn find_intent_trade( /// 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( +/// +/// Also the candidate set the decoder driver's contract-flag prefetch enumerates ahead of decode +/// — the two call sites must agree on `exclude` for the prefetch to cover what `find_intent_trade` +/// then looks up. +pub(crate) fn intent_candidates( transfer_ledger: &TransferLedger, exclude: &[Address], registry: &Registry, @@ -94,47 +108,13 @@ fn intent_candidates( 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 alloy::primitives::U256; 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 { @@ -145,35 +125,33 @@ mod tests { 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); - + #[test] + fn test_find_intent_trade_eoa_candidate() { + // Candidates in address order: addr(100) first — an EOA. let registry = Registry::ethereum(); - let mut cache = HashMap::new(); - let flow = find_intent_trade(&provider, &inverse_swap_ledger(), &[], ®istry, &mut cache) - .await - .unwrap(); + let contract_flags = HashMap::from([(addr(100), false)]); + let flow = + find_intent_trade(&inverse_swap_ledger(), &[], ®istry, &contract_flags).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(); + #[test] + fn test_find_intent_trade_all_candidates_contracts() { // 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 contract_flags = HashMap::from([(addr(100), true), (addr(101), true)]); + let flow = find_intent_trade(&inverse_swap_ledger(), &[], ®istry, &contract_flags); + assert!(flow.is_none()); + } + #[test] + fn test_find_intent_trade_unknown_candidate_declines() { + // A candidate absent from the prefetched facts (a prefetch gap, not expected in + // practice) is treated as a contract rather than guessed as an EOA. let registry = Registry::ethereum(); - let mut cache = HashMap::new(); - let flow = - find_intent_trade(&provider, &inverse_swap_ledger(), &[], ®istry, &mut cache).await; + let flow = find_intent_trade(&inverse_swap_ledger(), &[], ®istry, &HashMap::new()); assert!(flow.is_none()); } diff --git a/tools/hindsight/src/decoder/mod.rs b/tools/hindsight/src/decoder/mod.rs index 3b2ec8f6..3b81849b 100644 --- a/tools/hindsight/src/decoder/mod.rs +++ b/tools/hindsight/src/decoder/mod.rs @@ -30,7 +30,7 @@ mod veto; #[cfg(test)] mod test_utils; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use alloy::{ eips::BlockId, @@ -44,7 +44,7 @@ use futures::stream::StreamExt; use tracing::{debug, warn}; use crate::decoder::{ - decode::{recover, DecodeContext, GasScope, TraderFlow}, + decode::{recover, DecodeContext, GasScope, TraderFlow, TraderRole}, matching::MatchedSolverTrade, trace::{collect_native_transfers, fetch_trace, route_gas}, transfer_ledger::TransferLedger, @@ -213,6 +213,22 @@ fn intent_fields(intent: Option<&SwapIntent>) -> (Option, Option, Op (min_amount_out, declared_quote, quote_timestamp) } +/// Whether `address` has contract code. On RPC failure, treated as a contract — the conservative +/// default so an unknown address is never mistaken for an EOA swapper (see +/// `intents::netting::find_intent_trade`). +/// +/// 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 fetch_contract_flag(provider: &P, address: Address) -> bool { + 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 + } + } +} + /// 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. @@ -249,13 +265,14 @@ impl Decoder

{ /// Decode solver trades from a block — settled and reverted alike, as one list told apart by /// `DecodedTrade::status`. /// - /// Fetches all receipts in one `eth_getBlockReceipts` call, then matches each transaction one - /// of three ways: a settled trade, matched by entry point or by a known solver's log (the log - /// path catches filler-initiated intent fills, `UniswapX`, 1inch limit orders, where `tx.to` - /// is a rotating filler); a reverted candidate, matched by entry point alone (a revert emits - /// no logs — see `matching::select`); or neither, and the transaction is dropped before it - /// costs a trace. Both matched shapes join one bounded trace wave; the trace recovers native - /// ETH flows and attributes the settling (or attempted) solver either way. + /// Fetches all receipts in one `eth_getBlockReceipts` call, matches each transaction as a + /// settled trade (entry point or a known solver's log — the log path catches + /// filler-initiated intent fills, `UniswapX`, 1inch limit orders, where `tx.to` is a rotating + /// filler) or a reverted candidate (entry point alone — a revert emits no logs; see + /// `matching::select`), or drops it before it costs a trace. Every matched transaction joins + /// one bounded trace wave, then contract-or-EOA facts for this block's intent-role candidates + /// are prefetched in one batch (`prefetch_contract_flags`) so per-transaction decode itself + /// issues no RPC of its own. pub(crate) async fn decode_block( &mut self, block_number: u64, @@ -304,6 +321,9 @@ impl Decoder

{ .collect::>() .await; + self.prefetch_contract_flags(&matched, &traces) + .await; + let mut trades = Vec::new(); for ((index, matched), trace) in matched.into_iter().zip(traces) { let tx_index = matched @@ -334,11 +354,61 @@ impl Decoder

{ Ok(trades) } + /// Gather contract-or-EOA facts for this block's intent-role candidates, so per-tx decode + /// reads no RPC of its own (see `intents::netting::find_intent_trade`). Scoped to exactly the + /// candidate set `intents::netting::intent_candidates` would enumerate for each settled + /// intent-role trade in this block — not every address in every ledger, and not reverted + /// candidates, which never reach the `TraderFlow` decoder chain at all. Results join the + /// cross-block `code_cache`, so a recurring candidate (a router, a pool) costs one RPC call + /// for the life of the run. + async fn prefetch_contract_flags( + &mut self, + matched: &[(usize, MatchedSolverTrade<'_>)], + traces: &[anyhow::Result], + ) { + let mut candidates = HashSet::new(); + for ((_, trade), trace) in matched.iter().zip(traces) { + if trade.reverted { + continue; + } + let Ok(root) = trace else { continue }; + if !matches!( + TraderRole::classify(trade.entry_point, &self.registry), + TraderRole::Intent + ) { + continue; + } + let mut native = Vec::new(); + collect_native_transfers(root, &mut native); + let ledger = TransferLedger::from_transaction(trade.receipt.logs(), &native); + let exclude = [trade.entry_point, trade.receipt.from]; + for (candidate, _) in + intents::netting::intent_candidates(&ledger, &exclude, &self.registry) + { + if !self.code_cache.contains_key(&candidate) { + candidates.insert(candidate); + } + } + } + if candidates.is_empty() { + return; + } + let provider = &self.provider; + let fetched: Vec<(Address, bool)> = futures::stream::iter(candidates) + .map(|address| async move { (address, fetch_contract_flag(provider, address).await) }) + .buffered(TRACE_CONCURRENCY) + .collect() + .await; + self.code_cache.extend(fetched); + } + /// Decode one matched transaction: settled trades run the full `TraderFlow` decoder chain /// (`decode_settled`); reverted candidates skip it — there is no netted flow to decode, only - /// the settling solver frame's own calldata to read (`decode_reverted`). + /// the settling solver frame's own calldata to read (`decode_reverted`). Neither path issues + /// RPC of its own: settled decode reads the contract-flags prefetched for this block, and + /// reverted decode never needed RPC beyond the trace already fetched. async fn decode_transaction( - &mut self, + &self, matched: MatchedSolverTrade<'_>, root: &CallFrame, block_number: u64, @@ -406,15 +476,18 @@ impl Decoder

{ } /// Decode one settled 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. + /// decoders for its entity, veto non-trades, attribute the solver, and account gas and + /// quote. Issues no RPC — the one fact a decoder used to fetch lazily (is a candidate address + /// a contract or an EOA) was prefetched for the whole block before this ran (see + /// `prefetch_contract_flags`). async fn decode_settled( - &mut self, + &self, matched: MatchedSolverTrade<'_>, root: &CallFrame, block_number: u64, tx_index: u64, ) -> Option { - let Self { provider, registry, code_cache } = self; + let registry = &self.registry; let MatchedSolverTrade { receipt, entry_point, .. } = matched; let logs = receipt.logs(); let sender = receipt.from; @@ -424,9 +497,8 @@ impl Decoder

{ let transfer_ledger = TransferLedger::from_transaction(logs, &native); let mut ctx = DecodeContext { - provider, registry, - code_cache, + contract_flags: &self.code_cache, receipt, entry_point, transfer_ledger: &transfer_ledger, @@ -538,12 +610,14 @@ impl Decoder

{ #[cfg(test)] mod tests { use alloy::{ - primitives::{address, U256}, + primitives::{address, Bytes, U256}, providers::{mock::Asserter, ProviderBuilder}, }; use super::*; - use crate::decoder::test_utils::{addr, frame, make_transfer_log, receipt, tx_hash}; + use crate::decoder::test_utils::{ + addr, frame, make_pool_log, make_transfer_log, receipt, tx_hash, + }; /// 1inch v6 — a `[solvers]` entry in the ethereum address book, so a transaction into it /// matches on its entry point alone. @@ -629,4 +703,50 @@ mod tests { assert!(trade.amount_out.is_none()); assert!(trade.sandwich.is_none()); } + + #[tokio::test] + async fn test_intent_fill_prefetches_the_candidate_contract_flag() { + // An unregistered entry point (tx.to) classifies as an intent fill once one of the + // receipt's logs fingerprints a known solver (ONEINCH here) — the filler-initiated shape + // (`UniswapX`, 1inch limit orders, where `tx.to` is a rotating filler). `CowSettlement` + // declines (no Trade event), so `IntentNetting` looks for the swapper's net flow. + // ONEINCH doubles as the swap counterparty: since it is a known solver, it is excluded + // from `intent_candidates`, leaving the swapper as the sole candidate the driver's + // prefetch must resolve before decode runs — decode itself makes no RPC call for it. + let entry_point = addr(5); + let sender = addr(2); + let swapper = addr(100); + let token_a = addr(10); + let token_b = addr(11); + + let asserter = Asserter::new(); + asserter.push_success(&vec![receipt( + tx_hash(1), + sender, + Some(entry_point), + vec![ + make_pool_log(ONEINCH), + make_transfer_log(token_a, swapper, ONEINCH, U256::from(1_000)), + make_transfer_log(token_b, ONEINCH, swapper, U256::from(2_000)), + ], + )]); + asserter.push_success(&frame("CALL", sender, entry_point, 0)); + // The prefetch's one `eth_getCode` call, for the swapper — an EOA. + asserter.push_success(&Bytes::default()); + + let mut decoder = Decoder::new( + ProviderBuilder::default().connect_mocked_client(asserter), + Registry::ethereum(), + ); + let trades = decoder + .decode_block(21_000_000) + .await + .expect("decode_block should succeed"); + + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].sender, swapper); + assert_eq!(trades[0].decoder, "intent-netting"); + assert_eq!(trades[0].token_in, Some(token_a)); + assert_eq!(trades[0].token_out, Some(token_b)); + } } diff --git a/tools/hindsight/src/decoder/netting_decoders.rs b/tools/hindsight/src/decoder/netting_decoders.rs index d2f5f76b..244b4dc4 100644 --- a/tools/hindsight/src/decoder/netting_decoders.rs +++ b/tools/hindsight/src/decoder/netting_decoders.rs @@ -14,7 +14,7 @@ use std::collections::HashSet; -use alloy::{primitives::Address, providers::Provider}; +use alloy::primitives::Address; use async_trait::async_trait; use crate::decoder::{ @@ -110,12 +110,12 @@ fn back_out_venue_fees( pub(crate) struct SenderNetting; #[async_trait] -impl TradeDecoder

for SenderNetting { +impl TradeDecoder for SenderNetting { fn name(&self) -> &'static str { "sender-netting" } - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option { sender_flow(ctx.transfer_ledger, ctx.receipt.from, ctx.entry_point) } } diff --git a/tools/hindsight/src/decoder/venues/coinbase.rs b/tools/hindsight/src/decoder/venues/coinbase.rs index 396ba5b9..518f37aa 100644 --- a/tools/hindsight/src/decoder/venues/coinbase.rs +++ b/tools/hindsight/src/decoder/venues/coinbase.rs @@ -5,7 +5,6 @@ //! 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::{ @@ -17,13 +16,13 @@ use crate::decoder::{ pub(crate) struct CoinbaseNetting; #[async_trait] -impl TradeDecoder

for CoinbaseNetting { +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 { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option { let addresses = ctx.venue?; venue_flow( ctx.transfer_ledger, @@ -38,12 +37,7 @@ impl TradeDecoder

for CoinbaseNetting { mod tests { use std::collections::HashMap; - use alloy::{ - primitives::{Address, U256}, - providers::RootProvider, - rpc::client::RpcClient, - transports::mock::Asserter, - }; + use alloy::primitives::{Address, U256}; use super::*; use crate::decoder::{ @@ -68,14 +62,12 @@ mod tests { sender: Address, entry_point: Address, ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); + let contract_flags = HashMap::new(); let receipt = receipt(tx_hash(1), sender, Some(entry_point), vec![]); let root = frame("CALL", sender, entry_point, 0); let mut ctx = DecodeContext { - provider: &provider, registry, - code_cache: &mut code_cache, + contract_flags: &contract_flags, receipt: &receipt, entry_point, transfer_ledger: ledger, diff --git a/tools/hindsight/src/decoder/venues/metamask.rs b/tools/hindsight/src/decoder/venues/metamask.rs index eae4787f..d5ceb8ac 100644 --- a/tools/hindsight/src/decoder/venues/metamask.rs +++ b/tools/hindsight/src/decoder/venues/metamask.rs @@ -7,7 +7,7 @@ //! `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 alloy::{sol, sol_types::SolCall}; use async_trait::async_trait; use crate::decoder::{ @@ -26,14 +26,14 @@ sol! { pub(crate) struct MetaMaskNetting; #[async_trait] -impl TradeDecoder

for MetaMaskNetting { +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 { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option { let addresses = ctx.venue?; let mut flow = venue_flow( ctx.transfer_ledger, @@ -62,12 +62,7 @@ fn solver_from_calldata(input: &[u8], metamask: &VenueAddresses) -> Option Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); + let contract_flags = HashMap::new(); let receipt = receipt(tx_hash(1), sender, Some(entry_point), vec![]); let root = frame("CALL", sender, entry_point, 0); let mut ctx = DecodeContext { - provider: &provider, registry, - code_cache: &mut code_cache, + contract_flags: &contract_flags, receipt: &receipt, entry_point, transfer_ledger: ledger, diff --git a/tools/hindsight/src/decoder/venues/mod.rs b/tools/hindsight/src/decoder/venues/mod.rs index eda8c24e..904bbbb0 100644 --- a/tools/hindsight/src/decoder/venues/mod.rs +++ b/tools/hindsight/src/decoder/venues/mod.rs @@ -30,14 +30,12 @@ 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>> { +pub(crate) fn decoders_for(name: &str) -> Vec> { match name { "relay" => vec![Box::new(relay::RelayCalldata), Box::new(relay::RelayNetting)], "metamask" => vec![Box::new(metamask::MetaMaskNetting)], @@ -50,9 +48,8 @@ pub(crate) fn decoders_for(name: &str) -> Vec bool { - !decoders_for::(name).is_empty() + !decoders_for(name).is_empty() } #[cfg(test)] diff --git a/tools/hindsight/src/decoder/venues/rabby.rs b/tools/hindsight/src/decoder/venues/rabby.rs index 27a1bd39..47032e6d 100644 --- a/tools/hindsight/src/decoder/venues/rabby.rs +++ b/tools/hindsight/src/decoder/venues/rabby.rs @@ -13,7 +13,7 @@ //! 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 alloy::primitives::Address; use async_trait::async_trait; use crate::decoder::{ @@ -25,14 +25,14 @@ use crate::decoder::{ pub(crate) struct RabbyNetting; #[async_trait] -impl TradeDecoder

for RabbyNetting { +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 { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option { let addresses = ctx.venue?; let mut flow = venue_flow( ctx.transfer_ledger, @@ -60,10 +60,7 @@ impl TradeDecoder

for RabbyNetting { mod tests { use std::collections::HashMap; - use alloy::{ - primitives::U256, providers::RootProvider, rpc::client::RpcClient, - transports::mock::Asserter, - }; + use alloy::primitives::U256; use super::*; use crate::decoder::{ @@ -90,14 +87,12 @@ mod tests { sender: Address, entry_point: Address, ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); + let contract_flags = HashMap::new(); let receipt = receipt(tx_hash(1), sender, Some(entry_point), vec![]); let root = frame("CALL", sender, entry_point, 0); let mut ctx = DecodeContext { - provider: &provider, registry, - code_cache: &mut code_cache, + contract_flags: &contract_flags, receipt: &receipt, entry_point, transfer_ledger: ledger, diff --git a/tools/hindsight/src/decoder/venues/rainbow.rs b/tools/hindsight/src/decoder/venues/rainbow.rs index 0f9d55c2..f2225c2c 100644 --- a/tools/hindsight/src/decoder/venues/rainbow.rs +++ b/tools/hindsight/src/decoder/venues/rainbow.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; -use alloy::{primitives::U256, providers::Provider, sol, sol_types::SolCall}; +use alloy::{primitives::U256, sol, sol_types::SolCall}; use async_trait::async_trait; use crate::decoder::{ @@ -29,14 +29,14 @@ sol! { pub(crate) struct RainbowCalldata; #[async_trait] -impl TradeDecoder

for RainbowCalldata { +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 { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> 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. @@ -60,12 +60,7 @@ fn eth_to_token_fee(input: &[u8]) -> Option { mod tests { use std::collections::HashMap; - use alloy::{ - primitives::{Address, U256}, - providers::RootProvider, - rpc::client::RpcClient, - transports::mock::Asserter, - }; + use alloy::primitives::{Address, U256}; use super::*; use crate::decoder::{ @@ -91,15 +86,13 @@ mod tests { entry_point: Address, ) -> Option { let registry = Registry::ethereum(); - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); + let contract_flags = HashMap::new(); let user = addr(1); let receipt = receipt(tx_hash(1), user, Some(entry_point), vec![]); let root = frame("CALL", user, entry_point, 0); let mut ctx = DecodeContext { - provider: &provider, registry: ®istry, - code_cache: &mut code_cache, + contract_flags: &contract_flags, receipt: &receipt, entry_point, transfer_ledger: ledger, diff --git a/tools/hindsight/src/decoder/venues/relay.rs b/tools/hindsight/src/decoder/venues/relay.rs index 6d966beb..0697d434 100644 --- a/tools/hindsight/src/decoder/venues/relay.rs +++ b/tools/hindsight/src/decoder/venues/relay.rs @@ -11,10 +11,7 @@ use std::collections::HashSet; -use alloy::{ - primitives::{Address, U256}, - providers::Provider, -}; +use alloy::primitives::{Address, U256}; use async_trait::async_trait; use crate::decoder::{ @@ -43,12 +40,12 @@ use crate::decoder::{ pub(crate) struct RelayCalldata; #[async_trait] -impl TradeDecoder

for RelayCalldata { +impl TradeDecoder for RelayCalldata { fn name(&self) -> &'static str { "relay-calldata" } - async fn decode(&self, ctx: &mut DecodeContext<'_, P>) -> Option { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option { let addresses = ctx.venue?; let solver_frame = trace::find_solver_frame(ctx.root, ctx.registry)?; let solver = ctx.registry.label(solver_frame.to?); @@ -115,7 +112,7 @@ impl TradeDecoder

for RelayCalldata { pub(crate) struct RelayNetting; #[async_trait] -impl TradeDecoder

for RelayNetting { +impl TradeDecoder for RelayNetting { fn name(&self) -> &'static str { "relay-netting" } @@ -124,7 +121,7 @@ impl TradeDecoder

for RelayNetting { /// 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 { + async fn decode(&self, ctx: &mut DecodeContext<'_>) -> Option { let addresses = ctx.venue?; if let Some(flow) = venue_flow( ctx.transfer_ledger, @@ -216,11 +213,7 @@ fn decode_rebalance( mod tests { use std::collections::HashMap; - use alloy::{ - providers::RootProvider, - rpc::{client::RpcClient, types::Log}, - transports::mock::Asserter, - }; + use alloy::rpc::types::Log; use super::*; use crate::decoder::{ @@ -250,14 +243,12 @@ mod tests { sender: Address, entry_point: Address, ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); + let contract_flags = HashMap::new(); let receipt = receipt(tx_hash(1), sender, Some(entry_point), vec![]); let root = frame("CALL", sender, entry_point, 0); let mut ctx = DecodeContext { - provider: &provider, registry, - code_cache: &mut code_cache, + contract_flags: &contract_flags, receipt: &receipt, entry_point, transfer_ledger: ledger, @@ -491,13 +482,11 @@ mod tests { sender: Address, router: Address, ) -> Option { - let provider = RootProvider::new(RpcClient::mocked(Asserter::new())); - let mut code_cache = HashMap::new(); + let contract_flags = HashMap::new(); let receipt = receipt(tx_hash(1), sender, Some(router), vec![]); let mut ctx = DecodeContext { - provider: &provider, registry, - code_cache: &mut code_cache, + contract_flags: &contract_flags, receipt: &receipt, entry_point: router, transfer_ledger: ledger,