Decoder {
&self.registry
}
- /// Decode solver trades from a block.
+ /// 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 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, 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.
pub(crate) async fn decode_block(
&mut self,
block_number: u64,
@@ -251,20 +304,17 @@ impl Decoder {
.collect::>()
.await;
- let mut trades = Vec::with_capacity(matched.len());
+ let mut trades = Vec::new();
for ((index, matched), trace) in matched.into_iter().zip(traces) {
let tx_index = matched
.receipt
.transaction_index
.unwrap_or(index as u64);
+ let tx_hash = matched.receipt.transaction_hash;
let root = match trace {
Ok(root) => root,
Err(e) => {
- warn!(
- block = block_number,
- tx = %matched.receipt.transaction_hash,
- "skipping untraceable transaction: {e}"
- );
+ warn!(block = block_number, tx = %tx_hash, "skipping untraceable transaction: {e}");
crate::telemetry::record_untraced_transaction();
continue;
}
@@ -273,25 +323,99 @@ impl Decoder {
.decode_transaction(matched, &root, block_number, tx_index)
.await
{
- let evidence = sandwich::detect(&receipts, index, &trade, &self.registry);
- trade.sandwich = evidence;
+ // Sandwich detection only makes sense around a trade that actually moved the
+ // pools; a reverted trade has nothing to be sandwiched around.
+ if trade.status == TradeStatus::Settled {
+ trade.sandwich = sandwich::detect(&receipts, index, &trade, &self.registry);
+ }
trades.push(trade);
}
}
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: 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`).
async fn decode_transaction(
&mut self,
matched: MatchedSolverTrade<'_>,
root: &CallFrame,
block_number: u64,
tx_index: u64,
+ ) -> Option {
+ if matched.reverted {
+ return Some(self.decode_reverted(matched, root, block_number, tx_index));
+ }
+ self.decode_settled(matched, root, block_number, tx_index)
+ .await
+ }
+
+ /// Decode a reverted candidate from its trace: attribute the solver the same way a settled
+ /// trade would be (the strict-then-tolerant `find_solver_frame` walk falls back to the frame
+ /// that tried, since nothing settled), recover its swap terms when that solver's calldata
+ /// supports it, and classify why the transaction failed. The terms are `None` — not an
+ /// error — when no solver frame was found, its solver has no `swap_intent` support, or the
+ /// calldata did not parse; the trade is still recorded so parser coverage is measurable
+ /// against every reverted candidate. Always produces a trade: unlike a settled decode, there
+ /// is no veto or missing-flow case to decline on.
+ fn decode_reverted(
+ &self,
+ matched: MatchedSolverTrade<'_>,
+ root: &CallFrame,
+ block_number: u64,
+ tx_index: u64,
+ ) -> DecodedTrade {
+ let registry = &self.registry;
+ let MatchedSolverTrade { receipt, entry_point, .. } = matched;
+ let sender = receipt.from;
+ let venue = registry.label(entry_point);
+ let attribution =
+ solvers::attribution::attribute(None, root, entry_point, sender, registry);
+ // A reverted trade has no netted flow to draw an input-amount hint from — only the
+ // ABI/offset-based extractors (Fly, KyberSwap) can recover an intent here.
+ let intent = trace::find_solver_frame(root, registry)
+ .and_then(|frame| solvers::swap_intent(&attribution.solver, &frame.input, None));
+ let (min_amount_out, declared_quote, quote_timestamp) = intent_fields(intent.as_ref());
+ let (token_in, token_out, amount_in) = match &intent {
+ Some(intent) => (Some(intent.token_in), Some(intent.token_out), Some(intent.amount_in)),
+ None => (None, None, None),
+ };
+ DecodedTrade {
+ tx_hash: receipt.transaction_hash,
+ block_number,
+ tx_index,
+ status: TradeStatus::Reverted { cause: trace::classify_revert_cause(root) },
+ venue,
+ solver: attribution.solver,
+ solver_source: attribution.source,
+ decoder: "reverted",
+ sender,
+ token_in,
+ token_out,
+ amount_in,
+ amount_out: None,
+ venue_fee_in: None,
+ venue_fee_out: None,
+ settled_gas: None,
+ min_amount_out,
+ declared_quote,
+ quote_timestamp,
+ sandwich: None,
+ }
+ }
+
+ /// 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.
+ async fn decode_settled(
+ &mut self,
+ matched: MatchedSolverTrade<'_>,
+ root: &CallFrame,
+ block_number: u64,
+ tx_index: u64,
) -> Option {
let Self { provider, registry, code_cache } = self;
- let MatchedSolverTrade { receipt, entry_point } = matched;
+ let MatchedSolverTrade { receipt, entry_point, .. } = matched;
let logs = receipt.logs();
let sender = receipt.from;
@@ -388,15 +512,16 @@ impl Decoder {
tx_hash: receipt.transaction_hash,
block_number,
tx_index,
+ status: TradeStatus::Settled,
venue,
solver: attribution.solver,
solver_source: attribution.source,
decoder,
sender: flow.tracked,
- token_in: flow.swap.token_in,
- token_out: flow.swap.token_out,
- amount_in: flow.swap.amount_in,
- amount_out: flow.swap.amount_out,
+ token_in: Some(flow.swap.token_in),
+ token_out: Some(flow.swap.token_out),
+ amount_in: Some(flow.swap.amount_in),
+ amount_out: Some(flow.swap.amount_out),
venue_fee_in: flow.venue_fee_in,
venue_fee_out: flow.venue_fee_out,
settled_gas,
@@ -461,4 +586,47 @@ mod tests {
assert_eq!(trades.len(), 1);
assert_eq!(trades[0].tx_hash, tx_hash(2));
}
+
+ /// A Relay transaction that reverted before settling, decoded end to end through
+ /// `decode_block`: matched by entry point alone, attributed via the trace's tolerant
+ /// fallback, and recorded with no swap terms (an unregistered "solver" frame, so its calldata
+ /// carries no `swap_intent`).
+ #[tokio::test]
+ async fn test_reverted_relay_transaction_decodes_with_a_reverted_status() {
+ let registry = Registry::ethereum();
+ let relay = *registry
+ .venue("relay")
+ .unwrap()
+ .entry_points
+ .iter()
+ .next()
+ .unwrap();
+ let sender = addr(1);
+
+ let asserter = Asserter::new();
+ asserter.push_success(&vec![crate::decoder::test_utils::reverted_receipt(
+ tx_hash(1),
+ sender,
+ Some(relay),
+ )]);
+ let mut root = frame("CALL", sender, relay, 0);
+ root.error = Some("execution reverted".to_string());
+
+ asserter.push_success(&root);
+
+ let mut decoder =
+ Decoder::new(ProviderBuilder::default().connect_mocked_client(asserter), registry);
+ let trades = decoder
+ .decode_block(21_000_000)
+ .await
+ .expect("decode_block should succeed");
+
+ assert_eq!(trades.len(), 1);
+ let trade = &trades[0];
+ assert!(matches!(trade.status, TradeStatus::Reverted { .. }));
+ assert_eq!(trade.venue, "relay");
+ assert!(trade.token_in.is_none());
+ assert!(trade.amount_out.is_none());
+ assert!(trade.sandwich.is_none());
+ }
}
diff --git a/tools/hindsight/src/decoder/sandwich.rs b/tools/hindsight/src/decoder/sandwich.rs
index 57e4cdfbb..0411a580a 100644
--- a/tools/hindsight/src/decoder/sandwich.rs
+++ b/tools/hindsight/src/decoder/sandwich.rs
@@ -63,7 +63,10 @@ pub(crate) fn detect(
if victim_pools.is_empty() {
return None;
}
- let token = direction_token(victim.token_out, registry);
+ // Sandwich detection only makes sense for a trade that actually moved the pools; a reverted
+ // trade (or one whose calldata did not parse) has no settled output token to key the
+ // direction on.
+ let token = direction_token(victim.token_out?, registry);
let front_start = victim_index.saturating_sub(WINDOW);
let back_end = (victim_index + WINDOW).min(receipts.len().saturating_sub(1));
@@ -272,15 +275,16 @@ mod tests {
tx_hash: TxHash::default(),
block_number: 1,
tx_index: 0,
+ status: crate::decoder::TradeStatus::Settled,
venue: "relay".into(),
solver: "1inch".into(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
sender,
- token_in: addr(59),
- token_out,
- amount_in: U256::from(1_000u64),
- amount_out: U256::from(2_000u64),
+ token_in: Some(addr(59)),
+ token_out: Some(token_out),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: Some(U256::from(2_000u64)),
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
diff --git a/tools/hindsight/src/decoder/solvers/fly.rs b/tools/hindsight/src/decoder/solvers/fly.rs
index 9512b7a01..da9f4cca6 100644
--- a/tools/hindsight/src/decoder/solvers/fly.rs
+++ b/tools/hindsight/src/decoder/solvers/fly.rs
@@ -23,6 +23,10 @@ const SELECTORS: [[u8; 4]; 5] = [
[0x62, 0x7d, 0xd5, 0x6a],
];
+/// Fly's `InsufficientAmountOut()` custom error selector (`DexAggregator.sol`) — the marker for a
+/// slippage-floor revert.
+const INSUFFICIENT_AMOUNT_OUT: [u8; 4] = [0xe5, 0x29, 0x70, 0xaa];
+
const TO_ADDRESS_OFFSET: usize = 72;
const FROM_ASSET_OFFSET: usize = 92;
const TO_ASSET_OFFSET: usize = 112;
@@ -108,6 +112,11 @@ impl SolverKnowledge for Fly {
has_fly_selector(input)?;
Some(Address::from_slice(input.get(TO_ADDRESS_OFFSET..TO_ADDRESS_OFFSET + ADDRESS_LEN)?))
}
+
+ /// `InsufficientAmountOut()`'s selector as the frame's revert output.
+ fn is_slippage_floor(&self, output: Option<&[u8]>, _revert_reason: Option<&str>) -> bool {
+ output.is_some_and(|output| output.starts_with(&INSUFFICIENT_AMOUNT_OUT))
+ }
}
#[cfg(test)]
@@ -133,7 +142,7 @@ mod tests {
assert_eq!(intent.token_out, Address::ZERO);
assert_eq!(intent.amount_in, U256::from(19_694_643u64));
assert_eq!(intent.min_amount_out, U256::from(10_217_898_321_149_381u64));
- assert_eq!(intent.quoted_amount_out(), U256::from(10_321_109_415_302_405u64));
+ assert_eq!(intent.declared_quote(), Some(U256::from(10_321_109_415_302_405u64)));
}
#[test]
@@ -193,6 +202,14 @@ mod tests {
assert!(Fly.swap_intent(&input, None).is_none());
}
+ #[test]
+ fn test_is_slippage_floor_matches_the_selector() {
+ assert!(Fly.is_slippage_floor(Some(&INSUFFICIENT_AMOUNT_OUT), None));
+ assert!(Fly.is_slippage_floor(Some(&[0xe5, 0x29, 0x70, 0xaa, 0x00, 0x01]), None));
+ assert!(!Fly.is_slippage_floor(Some(&[0xde, 0xad, 0xbe, 0xef]), None));
+ assert!(!Fly.is_slippage_floor(None, None));
+ }
+
#[test]
fn test_amount_out_min_above_expected_rejected() {
let mut input = real_input();
diff --git a/tools/hindsight/src/decoder/solvers/kyberswap.rs b/tools/hindsight/src/decoder/solvers/kyberswap.rs
index 34347c28e..e6c0f6d63 100644
--- a/tools/hindsight/src/decoder/solvers/kyberswap.rs
+++ b/tools/hindsight/src/decoder/solvers/kyberswap.rs
@@ -19,6 +19,9 @@ use crate::decoder::solvers::{SolverKnowledge, SwapIntent};
const KYBERSWAP_NATIVE: Address =
alloy::primitives::address!("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
+/// `KyberSwap`'s slippage-floor revert reason.
+const INSUFFICIENT_RETURN: &str = "Return amount is not enough";
+
sol! {
/// `MetaAggregationRouterV2.swap`'s parameter shape, verified against a live reverted trade
/// (tx 0xd3b7ffae…, Base): decoding recovered `srcToken`/`dstToken`/`amount`/`minReturnAmount`
@@ -122,6 +125,11 @@ impl SolverKnowledge for Kyberswap {
let call = swapCall::abi_decode(input).ok()?;
Some(call.execution.desc.dstReceiver)
}
+
+ /// "Return amount is not enough" as the frame's revert reason.
+ fn is_slippage_floor(&self, _output: Option<&[u8]>, revert_reason: Option<&str>) -> bool {
+ revert_reason.is_some_and(|reason| reason.contains(INSUFFICIENT_RETURN))
+ }
}
#[cfg(test)]
@@ -208,8 +216,8 @@ mod tests {
assert_eq!(intent.token_out, dst);
assert_eq!(intent.amount_in, U256::from(1_000_000u64));
assert_eq!(intent.min_amount_out, U256::from(990_000u64));
- // No clientData quote declared: the accessor falls back to the floor.
- assert_eq!(intent.quoted_amount_out(), U256::from(990_000u64));
+ // No clientData quote declared.
+ assert_eq!(intent.declared_quote(), None);
assert_eq!(intent.timestamp, None);
}
@@ -238,7 +246,7 @@ mod tests {
.swap_intent(&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.declared_quote(), Some(U256::from(70_400_409_935u64)));
assert_eq!(intent.timestamp, Some(1_783_421_726));
}
@@ -254,7 +262,7 @@ mod tests {
None,
)
.unwrap();
- assert_eq!(intent.quoted_amount_out(), U256::from(990_000u64));
+ assert_eq!(intent.declared_quote(), None);
assert_eq!(intent.timestamp, None);
}
@@ -282,6 +290,15 @@ mod tests {
.is_none());
}
+ #[test]
+ fn test_is_slippage_floor_matches_the_revert_reason() {
+ assert!(Kyberswap.is_slippage_floor(None, Some(INSUFFICIENT_RETURN)));
+ assert!(Kyberswap
+ .is_slippage_floor(None, Some("execution reverted: Return amount is not enough")));
+ assert!(!Kyberswap.is_slippage_floor(None, Some("execution reverted")));
+ assert!(!Kyberswap.is_slippage_floor(None, None));
+ }
+
#[test]
fn test_swap_intent_garbage_input() {
assert!(Kyberswap
diff --git a/tools/hindsight/src/decoder/solvers/mod.rs b/tools/hindsight/src/decoder/solvers/mod.rs
index a7cbe80b6..b8d760c3e 100644
--- a/tools/hindsight/src/decoder/solvers/mod.rs
+++ b/tools/hindsight/src/decoder/solvers/mod.rs
@@ -29,9 +29,8 @@ use crate::decoder::{registry::Registry, veto::Veto};
/// revert emits no logs to net a settled amount from. The declared quote is different: it is the
/// number the venue compared against at decision time — what the solver's API promised — as
/// opposed to the settled amount, which is what execution delivered. It is self-reported and not
-/// every solver declares one, so it is read through [`SwapIntent::quoted_amount_out`] (falls back
-/// to the floor) or [`SwapIntent::declared_quote`] (the raw value, for callers that must tell a
-/// real quote from the fallback).
+/// every solver declares one, so it is read through [`SwapIntent::declared_quote`], `None` when
+/// the calldata carried no quote.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub(crate) struct SwapIntent {
/// `Address::ZERO` for native ETH.
@@ -76,24 +75,7 @@ impl SwapIntent {
self
}
- /// The best available "what was promised": the solver's declared quote, or — when absent —
- /// the enforced floor.
- #[cfg_attr(
- not(test),
- expect(
- dead_code,
- reason = "only called from tests in this PR; its production caller is the \
- reverted-swap path in the stacked follow-up PR"
- )
- )]
- pub(crate) fn quoted_amount_out(&self) -> U256 {
- self.quoted_amount_out
- .unwrap_or(self.min_amount_out)
- }
-
- /// The raw declared quote, `None` when the calldata carried none. Distinct from
- /// [`SwapIntent::quoted_amount_out`], which falls back to the floor — analysts need to tell
- /// a real quote from the fallback.
+ /// The solver's declared off-chain quote, `None` when the calldata carried none.
pub(crate) fn declared_quote(&self) -> Option {
self.quoted_amount_out
}
@@ -147,6 +129,15 @@ pub(crate) trait SolverKnowledge: Send + Sync {
fn integrator(&self, _logs: &[Log]) -> Option {
None
}
+
+ /// Whether a reverted call frame's output or revert reason matches this solver's
+ /// slippage-floor marker — the avoidable class of revert a fresher quote could have cleared
+ /// (Fly's `InsufficientAmountOut()` selector, `KyberSwap`'s "Return amount is not enough"
+ /// revert reason). Checked against every frame in a reverted trace's subtree (see
+ /// `trace::classify_revert_cause`), so a solver need not be attributed yet to be recognized.
+ fn is_slippage_floor(&self, _output: Option<&[u8]>, _revert_reason: Option<&str>) -> bool {
+ false
+ }
}
/// The solvers with a `SolverKnowledge` implementation, by address-book name. A solver absent
@@ -211,6 +202,16 @@ pub(crate) fn output_recipient(solver: &str, input: &[u8]) -> Option {
knowledge.output_recipient(input)
}
+/// Whether a reverted call frame's output or revert reason matches any registered solver's
+/// slippage-floor marker. Unscoped by attribution — the marker is a hard fact about the frame's
+/// own bytes, and only one solver's check can ever match a given frame — so every implementation
+/// is tried.
+pub(crate) fn is_slippage_floor(output: Option<&[u8]>, revert_reason: Option<&str>) -> bool {
+ IMPLEMENTATIONS
+ .iter()
+ .any(|(_, knowledge)| knowledge.is_slippage_floor(output, revert_reason))
+}
+
/// Whether a declared quote is in the same units as the settled output.
///
/// Quotes are self-reported calldata: integrators sometimes fill them in a different token or
diff --git a/tools/hindsight/src/decoder/solvers/paraswap.rs b/tools/hindsight/src/decoder/solvers/paraswap.rs
index 48dff8cbc..fc367d69b 100644
--- a/tools/hindsight/src/decoder/solvers/paraswap.rs
+++ b/tools/hindsight/src/decoder/solvers/paraswap.rs
@@ -125,7 +125,7 @@ mod tests {
assert_eq!(intent.token_out, address_from_word(dst_token));
assert_eq!(intent.amount_in, amount_in);
assert_eq!(intent.min_amount_out, U256::from(171_430_663u64));
- assert_eq!(intent.quoted_amount_out(), U256::from(171_602_266u64));
+ assert_eq!(intent.declared_quote(), Some(U256::from(171_602_266u64)));
}
#[test]
diff --git a/tools/hindsight/src/decoder/solvers/zeroex.rs b/tools/hindsight/src/decoder/solvers/zeroex.rs
index 4a9e7c434..b0c2433b4 100644
--- a/tools/hindsight/src/decoder/solvers/zeroex.rs
+++ b/tools/hindsight/src/decoder/solvers/zeroex.rs
@@ -17,6 +17,17 @@
//! 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.
+//!
+//! `is_slippage_floor` covers 0x's own `TooMuchSlippage(address,uint256,uint256)`, thrown by
+//! `SettlerAbstract`/`SettlerErrors.sol` (`revertTooMuchSlippage`) — also verified against
+//! 0x-settler's source and confirmed live: the selector appears in real reverted Base traces
+//! bubbling through both registered 0x addresses (Settler and `AllowanceHolder`), full
+//! ABI-encoded as `(token, expected, actual)` (see
+//! `fixtures/trace_revert_zeroex_slippage_0x157e025b.json`, whose deepest frame decodes to
+//! `token=USDC, expected=0xdd2ab926, actual=0`). `"return too low"` is not 0x's own error — it is
+//! the revert string of at least one inner liquidity source 0x's Settler routes through (observed
+//! live from a `Kipseli PropAMM` pool) — but 0x is the only address our registry knows about
+//! anywhere in those traces, so it is recognized here rather than left unclassified.
use alloy::{
primitives::{Address, U256},
@@ -69,6 +80,13 @@ fn normalize_native(token: Address) -> Address {
}
}
+/// `keccak256("TooMuchSlippage(address,uint256,uint256)")[..4]`.
+const TOO_MUCH_SLIPPAGE: [u8; 4] = [0x97, 0xa6, 0xf3, 0xb9];
+
+/// Revert reason from a liquidity source 0x's Settler routed through, when its own output floor
+/// was not met.
+const RETURN_TOO_LOW: &str = "return too low";
+
/// Settler's own terms, decoded from an `execute` call regardless of how it was reached (wrapped
/// in `AllowanceHolder.exec` or, if it ever occurs, called directly).
struct SettlerTerms {
@@ -137,6 +155,14 @@ impl SolverKnowledge for ZeroEx {
}
decode_execute(input).map(|terms| terms.recipient)
}
+
+ /// Whether a reverted frame's output is 0x's own `TooMuchSlippage` selector, or its revert
+ /// reason is the "return too low" string a liquidity source 0x's Settler routes through
+ /// returns.
+ fn is_slippage_floor(&self, output: Option<&[u8]>, revert_reason: Option<&str>) -> bool {
+ output.is_some_and(|output| output.starts_with(&TOO_MUCH_SLIPPAGE)) ||
+ revert_reason.is_some_and(|reason| reason.contains(RETURN_TOO_LOW))
+ }
}
#[cfg(test)]
@@ -287,4 +313,26 @@ mod tests {
assert_eq!(normalize_native(ZEROEX_NATIVE), Address::ZERO);
assert_eq!(normalize_native(USDC), USDC);
}
+
+ #[test]
+ fn test_is_slippage_floor_matches_the_selector() {
+ // The full ABI-encoded shape (token, expected, actual) — only the selector is checked.
+ let mut output = TOO_MUCH_SLIPPAGE.to_vec();
+ output.extend_from_slice(&[0u8; 96]);
+ assert!(ZeroEx.is_slippage_floor(Some(&output), None));
+ assert!(!ZeroEx.is_slippage_floor(Some(&[0xde, 0xad, 0xbe, 0xef]), None));
+ }
+
+ #[test]
+ fn test_is_slippage_floor_matches_the_revert_reason() {
+ assert!(ZeroEx.is_slippage_floor(None, Some("return too low")));
+ // Substring, not exact match: geth's callTracer sometimes carries surrounding context.
+ assert!(ZeroEx.is_slippage_floor(None, Some("execution reverted: return too low")));
+ assert!(!ZeroEx.is_slippage_floor(None, Some("out of gas")));
+ }
+
+ #[test]
+ fn test_is_slippage_floor_declines_when_neither_is_present() {
+ assert!(!ZeroEx.is_slippage_floor(None, None));
+ }
}
diff --git a/tools/hindsight/src/decoder/test_utils.rs b/tools/hindsight/src/decoder/test_utils.rs
index c57909fe7..3b64b83c7 100644
--- a/tools/hindsight/src/decoder/test_utils.rs
+++ b/tools/hindsight/src/decoder/test_utils.rs
@@ -120,3 +120,35 @@ pub(crate) fn receipt(
contract_address: None,
})
}
+
+/// A synthetic reverted (status-0) receipt: no logs, since a reverted transaction emits none.
+pub(crate) fn reverted_receipt(
+ hash: TxHash,
+ from: Address,
+ to: Option,
+) -> AnyTransactionReceipt {
+ WithOtherFields::new(TransactionReceipt {
+ inner: AnyReceiptEnvelope {
+ inner: ReceiptWithBloom {
+ receipt: Receipt {
+ status: Eip658Value::Eip658(false),
+ cumulative_gas_used: 0,
+ logs: vec![],
+ },
+ logs_bloom: Bloom::default(),
+ },
+ r#type: 0,
+ },
+ transaction_hash: hash,
+ transaction_index: None,
+ block_hash: None,
+ block_number: None,
+ gas_used: 0,
+ effective_gas_price: 0,
+ blob_gas_used: None,
+ blob_gas_price: None,
+ from,
+ to,
+ contract_address: None,
+ })
+}
diff --git a/tools/hindsight/src/decoder/trace.rs b/tools/hindsight/src/decoder/trace.rs
index 5eb2f7654..abfe1c4db 100644
--- a/tools/hindsight/src/decoder/trace.rs
+++ b/tools/hindsight/src/decoder/trace.rs
@@ -5,7 +5,7 @@ use alloy::{
};
use anyhow::Context;
-use crate::decoder::registry::Registry;
+use crate::decoder::{registry::Registry, solvers};
/// Fetch the callTracer root frame for a transaction.
///
@@ -79,18 +79,40 @@ pub(crate) fn route_gas(root: &CallFrame, registry: &Registry) -> Option {
.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.
+/// Depth-first search for the call frame that settled or tried the swap with a known solver.
///
-/// The one walk serves both questions asked of a trace: *who* settled the swap (the frame's
-/// `to`, for attribution) and *what the route cost* (the frame's `gas_used`, for gas
-/// accounting) — so the gas charged is always the gas of the exact frame the solver label came
-/// from.
+/// Prefers the frame that actually settled — skipping reverted frames and their subtrees, which
+/// settle nothing — and, only when that search finds nothing, falls back to a tolerant search
+/// that descends into reverted frames too. Plain "ignore every revert" (the simpler-looking
+/// alternative) would get a settled trade wrong: a router that tried solver A (which reverted)
+/// before succeeding via solver B has both frames in its trace, and a revert-blind walk can
+/// attribute to whichever it reaches first in depth-first order, not whichever actually settled.
+/// The two-phase preference handles both shapes with one function: a settled trade always finds
+/// its real solver frame in the first (strict) pass, and a reverted trade — which has no settled
+/// frame by definition — falls through to the second (tolerant) pass, since a revert emits no
+/// logs and the frame that tried is the only frame there is to attribute to or recover calldata
+/// from (see the trace fixtures for both shapes observed live).
+///
+/// The one walk serves every question asked of a trace: *who* settled or tried the swap (the
+/// frame's `to`, for attribution and calldata extraction) and *what the route cost* (the frame's
+/// `gas_used`, for gas accounting) — so the gas charged is always the gas of the exact frame the
+/// solver label came from.
pub(crate) fn find_solver_frame<'a>(
frame: &'a CallFrame,
registry: &Registry,
) -> Option<&'a CallFrame> {
- if frame.error.is_some() {
+ find_solver_frame_impl(frame, registry, false)
+ .or_else(|| find_solver_frame_impl(frame, registry, true))
+}
+
+/// Shared walk for both passes of [`find_solver_frame`]: the only difference is whether a frame's
+/// own revert stops the search or not.
+fn find_solver_frame_impl<'a>(
+ frame: &'a CallFrame,
+ registry: &Registry,
+ tolerate_reverts: bool,
+) -> Option<&'a CallFrame> {
+ if frame.error.is_some() && !tolerate_reverts {
return None;
}
if let Some(to) = frame.to {
@@ -101,7 +123,103 @@ pub(crate) fn find_solver_frame<'a>(
frame
.calls
.iter()
- .find_map(|child| find_solver_frame(child, registry))
+ .find_map(|child| find_solver_frame_impl(child, registry, tolerate_reverts))
+}
+
+/// The geth call tracer's exact error string for a frame that ran out of gas.
+const OUT_OF_GAS: &str = "out of gas";
+
+/// Why a reverted transaction failed, classified from its trace.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
+pub(crate) enum RevertCause {
+ /// A slippage-floor revert: Fly's `InsufficientAmountOut()` or `KyberSwap`'s "Return amount is
+ /// not enough" — the class of revert a fresher quote could have avoided.
+ SlippageFloor,
+ /// A frame ran out of gas.
+ OutOfGas,
+ /// Any other cause, taken from the deepest reverted frame's error or revert reason.
+ Other(String),
+}
+
+/// Classify why a reverted transaction's root frame failed, from its whole reverted subtree.
+///
+/// Checked in order of specificity: a slippage-floor marker (Fly's custom-error selector in any
+/// frame's output, or `KyberSwap`'s revert reason string) anywhere in the subtree wins even when a
+/// deeper frame failed for an unrelated reason — real reverts often cascade through several
+/// frames, and the slippage-floor check is the one classification callers act on (it is the
+/// avoidable class). Otherwise, any frame with the exact "out of gas" error settles it. Anything
+/// else falls back to the deepest reverted frame's own error or revert reason.
+pub(crate) fn classify_revert_cause(root: &CallFrame) -> RevertCause {
+ if has_slippage_floor_marker(root) {
+ return RevertCause::SlippageFloor;
+ }
+ if has_out_of_gas(root) {
+ return RevertCause::OutOfGas;
+ }
+ RevertCause::Other(deepest_revert_reason(root).unwrap_or_else(|| "unknown revert".to_string()))
+}
+
+fn has_slippage_floor_marker(frame: &CallFrame) -> bool {
+ let output = frame.output.as_ref().map(AsRef::as_ref);
+ solvers::is_slippage_floor(output, frame.revert_reason.as_deref()) ||
+ frame
+ .calls
+ .iter()
+ .any(has_slippage_floor_marker)
+}
+
+fn has_out_of_gas(frame: &CallFrame) -> bool {
+ frame.error.as_deref() == Some(OUT_OF_GAS) || frame.calls.iter().any(has_out_of_gas)
+}
+
+/// The error or revert reason of the deepest reverted frame, by depth-first search — the last
+/// (deepest) hit wins, since a subtree's own failure is usually more specific than an ancestor's
+/// bubbled-up "execution reverted".
+fn deepest_revert_reason(frame: &CallFrame) -> Option {
+ let mut deepest: Option<(usize, String)> = None;
+ collect_deepest_revert_reason(frame, 0, &mut deepest);
+ deepest.map(|(_, reason)| reason)
+}
+
+fn collect_deepest_revert_reason(
+ frame: &CallFrame,
+ depth: usize,
+ deepest: &mut Option<(usize, String)>,
+) {
+ if let Some(reason) = frame_revert_reason(frame) {
+ if deepest
+ .as_ref()
+ .is_none_or(|(best_depth, _)| depth >= *best_depth)
+ {
+ *deepest = Some((depth, reason));
+ }
+ }
+ for child in &frame.calls {
+ collect_deepest_revert_reason(child, depth + 1, deepest);
+ }
+}
+
+/// One frame's revert reason: the tracer's own ABI-decoded string when it provides one, otherwise
+/// the generic error message with the frame's raw output selector appended when there is one to
+/// show (`"execution reverted (0x12345678)"`). Several RPC providers never populate
+/// `revert_reason` at all, even for a frame whose output does encode a selector (a custom error,
+/// or an `Error(string)` the tracer did not bother decoding) — the selector is what is left to
+/// classify offline, so it is surfaced rather than dropped.
+fn frame_revert_reason(frame: &CallFrame) -> Option {
+ if let Some(reason) = frame.revert_reason.clone() {
+ return Some(reason);
+ }
+ let error = frame.error.clone()?;
+ let selector = frame
+ .output
+ .as_ref()
+ .filter(|output| output.len() >= 4)
+ .map(|output| format!("0x{}", alloy::hex::encode(&output[..4])));
+ Some(match selector {
+ Some(selector) => format!("{error} ({selector})"),
+ None => error,
+ })
}
/// Best guess at an unknown router: the entry point's direct child call that moved the most
@@ -144,6 +262,7 @@ pub(crate) fn largest_external_call(
#[cfg(test)]
mod tests {
use alloy::primitives::address;
+ use tycho_simulation::tycho_common::models::Chain;
use super::*;
use crate::decoder::test_utils::{addr, frame};
@@ -270,15 +389,183 @@ mod tests {
}
#[test]
- fn test_find_solver_frame_reverted_frames() {
+ fn test_find_solver_frame_prefers_a_settled_frame_over_a_reverted_attempt() {
+ // The exact shape the doc comment calls out: a router tried solver A (which reverted)
+ // before succeeding via solver B. The strict pass must attribute to B, not whichever it
+ // reaches first in depth-first order.
let registry = Registry::ethereum();
let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65");
+ let zerox = address!("0x0000000000001ff3684f28c67538d4d072c22734");
- let mut reverted = frame("CALL", addr(2), oneinch, 0);
- reverted.error = Some("execution reverted".to_string());
+ let mut attempt_a = frame("CALL", addr(2), oneinch, 0);
+ attempt_a.error = Some("execution reverted".to_string());
+ let attempt_b = frame("CALL", addr(2), zerox, 0);
let mut root = frame("CALL", addr(1), addr(2), 0);
- root.calls = vec![reverted];
+ root.calls = vec![attempt_a, attempt_b];
+
+ let found = find_solver_frame(&root, ®istry).unwrap();
+ assert_eq!(found.to, Some(zerox));
+ }
+
+ #[test]
+ fn test_find_solver_frame_falls_back_when_nothing_settled() {
+ // A reverted trade has no settled frame by definition: the strict pass finds nothing, so
+ // the tolerant fallback is the only way to recover which router the trader was routed
+ // through — the frame that tried is the only frame there is to find.
+ let registry = Registry::ethereum();
+ let oneinch = address!("0x111111125421ca6dc452d289314280a0f8842a65");
- assert!(find_solver_frame(&root, ®istry).is_none());
+ let mut solver_call = frame("CALL", addr(2), oneinch, 0);
+ solver_call.error = Some("execution reverted".to_string());
+ let mut root = frame("CALL", addr(1), addr(2), 0);
+ root.error = Some("execution reverted".to_string());
+ root.calls = vec![solver_call];
+
+ let found = find_solver_frame(&root, ®istry).unwrap();
+ assert_eq!(found.to, Some(oneinch));
+ }
+
+ #[test]
+ fn test_find_solver_frame_real_fly_slippage_trace() {
+ // Real reverted trace (Base, Relay -> Fly): the fly frame itself reverted
+ // (InsufficientAmountOut), nested inside the reverted router and approval-proxy frames.
+ // Nothing settled, so the strict pass finds nothing and the fallback recovers it.
+ let root: CallFrame = serde_json::from_str(include_str!(
+ "fixtures/trace_revert_fly_slippage_0xcba01d0c.json"
+ ))
+ .unwrap();
+ let fly = address!("0x20f6ee51340adeed01a59b0e65cb3703f3dc860c");
+ let found = find_solver_frame(&root, &Registry::builtin(Chain::Base).unwrap()).unwrap();
+ assert_eq!(found.to, Some(fly));
+ }
+
+ #[test]
+ fn test_find_solver_frame_real_out_of_gas_trace() {
+ // Real reverted trace (Base): the fly frame itself succeeded (err = None) — the router
+ // ran out of gas on a later, unrelated call — so the strict pass already finds it, even
+ // though it sits under two reverted ancestors.
+ let root: CallFrame =
+ serde_json::from_str(include_str!("fixtures/trace_revert_out_of_gas_0x08fee57c.json"))
+ .unwrap();
+ let fly = address!("0x20f6ee51340adeed01a59b0e65cb3703f3dc860c");
+ let found = find_solver_frame(&root, &Registry::builtin(Chain::Base).unwrap()).unwrap();
+ assert_eq!(found.to, Some(fly));
+ assert!(found.error.is_none());
+ }
+
+ #[test]
+ fn test_classify_revert_cause_real_fly_slippage() {
+ let root: CallFrame = serde_json::from_str(include_str!(
+ "fixtures/trace_revert_fly_slippage_0xcba01d0c.json"
+ ))
+ .unwrap();
+ assert_eq!(classify_revert_cause(&root), RevertCause::SlippageFloor);
+ }
+
+ #[test]
+ fn test_classify_revert_cause_real_kyber_slippage() {
+ let root: CallFrame = serde_json::from_str(include_str!(
+ "fixtures/trace_revert_kyber_slippage_0xd3b7ffae.json"
+ ))
+ .unwrap();
+ assert_eq!(classify_revert_cause(&root), RevertCause::SlippageFloor);
+ }
+
+ #[test]
+ fn test_classify_revert_cause_real_out_of_gas() {
+ let root: CallFrame =
+ serde_json::from_str(include_str!("fixtures/trace_revert_out_of_gas_0x08fee57c.json"))
+ .unwrap();
+ assert_eq!(classify_revert_cause(&root), RevertCause::OutOfGas);
+ }
+
+ #[test]
+ fn test_classify_revert_cause_real_other() {
+ // A `transferFrom` failure deep inside Fly's frame (a custom ERC-20 error, not one of the
+ // known slippage markers): neither Fly's nor KyberSwap's marker matches, so it falls back
+ // to the deepest reverted frame's own error — this RPC never populates `revertReason`, so
+ // the frame's raw output selector (0xe450d38c, `ERC20InsufficientBalance(address,uint256,
+ // uint256)`) is appended to the generic message rather than left unclassifiable.
+ let root: CallFrame = serde_json::from_str(include_str!(
+ "fixtures/trace_revert_transfer_failure_0x12d802d5.json"
+ ))
+ .unwrap();
+ assert_eq!(
+ classify_revert_cause(&root),
+ RevertCause::Other("execution reverted (0xe450d38c)".to_string())
+ );
+ }
+
+ #[test]
+ fn test_classify_revert_cause_synthetic_slippage_and_gas() {
+ let mut fly_floor = frame("CALL", addr(1), addr(2), 0);
+ fly_floor.error = Some("execution reverted".to_string());
+ fly_floor.output = Some(alloy::primitives::Bytes::from_static(&[0xe5, 0x29, 0x70, 0xaa]));
+ assert_eq!(classify_revert_cause(&fly_floor), RevertCause::SlippageFloor);
+
+ let mut kyber_floor = frame("CALL", addr(1), addr(2), 0);
+ kyber_floor.error = Some("execution reverted".to_string());
+ kyber_floor.revert_reason = Some("Return amount is not enough".to_string());
+ assert_eq!(classify_revert_cause(&kyber_floor), RevertCause::SlippageFloor);
+
+ let mut out_of_gas = frame("CALL", addr(1), addr(2), 0);
+ out_of_gas.error = Some("out of gas".to_string());
+ assert_eq!(classify_revert_cause(&out_of_gas), RevertCause::OutOfGas);
+ }
+
+ #[test]
+ fn test_classify_revert_cause_prefers_deepest_reason() {
+ let mut inner = frame("CALL", addr(2), addr(3), 0);
+ inner.error = Some("transferFrom failed".to_string());
+ let mut outer = frame("CALL", addr(1), addr(2), 0);
+ outer.error = Some("execution reverted".to_string());
+ outer.calls = vec![inner];
+
+ assert_eq!(
+ classify_revert_cause(&outer),
+ RevertCause::Other("transferFrom failed".to_string())
+ );
+ }
+
+ #[test]
+ fn test_classify_revert_cause_real_zeroex_slippage() {
+ // Real reverted trace (Base): 0x's own TooMuchSlippage(address,uint256,uint256) bubbles
+ // through the Settler and AllowanceHolder frames, both registered as solver "0x".
+ let root: CallFrame = serde_json::from_str(include_str!(
+ "fixtures/trace_revert_zeroex_slippage_0x157e025b.json"
+ ))
+ .unwrap();
+ assert_eq!(classify_revert_cause(&root), RevertCause::SlippageFloor);
+ }
+
+ #[test]
+ fn test_frame_revert_reason_appends_the_selector_when_undecoded() {
+ // No `revert_reason` (this RPC never populates it) but the frame's raw output carries a
+ // selector: the generic message gets it appended, so an unrecognized custom error stays
+ // classifiable offline instead of collapsing into a bare "execution reverted".
+ let mut custom_error = frame("CALL", addr(1), addr(2), 0);
+ custom_error.error = Some("execution reverted".to_string());
+ custom_error.output =
+ Some(alloy::primitives::Bytes::from_static(&[0x12, 0x34, 0x56, 0x78]));
+ assert_eq!(
+ frame_revert_reason(&custom_error),
+ Some("execution reverted (0x12345678)".to_string())
+ );
+ }
+
+ #[test]
+ fn test_frame_revert_reason_prefers_the_decoded_reason() {
+ let mut decoded = frame("CALL", addr(1), addr(2), 0);
+ decoded.error = Some("execution reverted".to_string());
+ decoded.revert_reason = Some("Return amount is not enough".to_string());
+ decoded.output = Some(alloy::primitives::Bytes::from_static(&[0x12, 0x34, 0x56, 0x78]));
+ assert_eq!(frame_revert_reason(&decoded), Some("Return amount is not enough".to_string()));
+ }
+
+ #[test]
+ fn test_frame_revert_reason_no_output_stays_bare() {
+ let mut bare = frame("CALL", addr(1), addr(2), 0);
+ bare.error = Some("execution reverted".to_string());
+ assert_eq!(frame_revert_reason(&bare), Some("execution reverted".to_string()));
}
}
diff --git a/tools/hindsight/src/main.rs b/tools/hindsight/src/main.rs
index 45db9be84..e73d4ba18 100644
--- a/tools/hindsight/src/main.rs
+++ b/tools/hindsight/src/main.rs
@@ -16,7 +16,7 @@ use tracing_subscriber::EnvFilter;
use tycho_simulation::tycho_common::models::Chain;
use crate::{
- decoder::{DecodedTrade, Decoder, Registry},
+ decoder::{DecodedTrade, Decoder, Registry, TradeStatus},
report::ReportArgs,
resolve::monitor::MonitorArgs,
verify::allium::AlliumClient,
@@ -182,11 +182,11 @@ async fn run_decode(args: DecodeArgs) -> anyhow::Result<()> {
for block_number in &blocks {
info!(block = block_number, "decoding solver trades");
let start = Instant::now();
- let trades = match decoder
+ let decoded = match decoder
.decode_block(*block_number)
.await
{
- Ok(trades) => trades,
+ Ok(decoded) => decoded,
Err(error) => {
warn!(block = block_number, %error, "failed to decode block; skipping");
continue;
@@ -194,12 +194,22 @@ async fn run_decode(args: DecodeArgs) -> anyhow::Result<()> {
};
let elapsed_ms = start.elapsed().as_millis();
- if trades.is_empty() {
+ if decoded.is_empty() {
info!(block = block_number, elapsed_ms, "no solver trades found");
} else {
- info!(block = block_number, count = trades.len(), elapsed_ms, "decoded trades");
+ let reverted = decoded
+ .iter()
+ .filter(|trade| trade.status != TradeStatus::Settled)
+ .count();
+ info!(
+ block = block_number,
+ trades = decoded.len(),
+ reverted,
+ elapsed_ms,
+ "decoded trades"
+ );
}
- all_trades.extend(trades);
+ all_trades.extend(decoded);
}
if args.json {
@@ -256,6 +266,9 @@ pub(crate) async fn resolve_blocks(
}
}
+/// Print every decoded trade — settled or reverted, told apart by `status`. A settled trade's
+/// terms are always known (see `DecodedTrade`'s settled/reverted invariant); a reverted one may
+/// have none, when its solver calldata did not parse.
#[expect(clippy::print_stdout)]
fn print_trades(trades: &[DecodedTrade]) {
if trades.is_empty() {
@@ -271,10 +284,14 @@ fn print_trades(trades: &[DecodedTrade]) {
println!(" venue: {}", trade.venue);
println!(" solver: {}", trade.solver);
println!(" sender: {}", trade.sender);
- println!(" token_in: {}", trade.token_in);
- println!(" amount_in: {}", trade.amount_in);
- println!(" token_out: {}", trade.token_out);
- println!(" amount_out: {}", trade.amount_out);
+ match &trade.status {
+ TradeStatus::Settled => println!(" status: settled"),
+ TradeStatus::Reverted { cause } => println!(" status: reverted ({cause:?})"),
+ }
+ print_option("token_in:", trade.token_in);
+ print_option("amount_in:", trade.amount_in);
+ print_option("token_out:", trade.token_out);
+ print_option("amount_out:", trade.amount_out);
if let Some(sandwich) = &trade.sandwich {
println!(
" sandwich: front={} back={} attacker={}",
@@ -285,6 +302,16 @@ fn print_trades(trades: &[DecodedTrade]) {
}
}
+/// Print a labeled field that may be unknown — `unknown` (not blank) so a reverted trade whose
+/// terms did not parse still reads as "we checked and found nothing" rather than an empty value.
+#[expect(clippy::print_stdout)]
+fn print_option(label: &str, value: Option) {
+ match value {
+ Some(value) => println!(" {label:<12}{value}"),
+ None => println!(" {label:<12}unknown"),
+ }
+}
+
const MAX_RANGE_BLOCKS: u64 = 1000;
fn parse_range(range: &str) -> anyhow::Result> {
diff --git a/tools/hindsight/src/report/aggregate.rs b/tools/hindsight/src/report/aggregate.rs
index 793c0ba1e..46aa05261 100644
--- a/tools/hindsight/src/report/aggregate.rs
+++ b/tools/hindsight/src/report/aggregate.rs
@@ -5,7 +5,7 @@
use std::collections::HashMap;
-use crate::report::record::Comparison;
+use crate::report::record::{Comparison, State};
/// Number of trades listed in the biggest-wins and biggest-losses tables.
const TOP_TRADES: usize = 10;
@@ -75,14 +75,27 @@ pub(crate) struct GroupStats {
/// One row in the biggest-wins or biggest-losses table.
pub(crate) struct TradeRow {
- pub settled_tx: String,
+ pub tx_hash: String,
pub venue: String,
pub solver: String,
pub net_bps: Option,
pub improvement_usd: f64,
}
-/// Aggregate every view from the parsed records.
+/// A settled record's `top` state. `report::run` filters records to `status == "settled"` before
+/// this module ever sees them, and a settled trade always carries a `top` state by construction
+/// (see `DecodedTrade`'s settled/reverted invariant) — a missing one here means that filter was
+/// bypassed.
+fn top(record: &Comparison) -> &State {
+ record
+ .top
+ .as_ref()
+ .expect("settled comparison record must carry a top state")
+}
+
+/// Aggregate every view from the parsed records. Callers must pre-filter to `status == "settled"`
+/// (see `report::run`) — every aggregation here is judged on `top`, which only a settled record is
+/// guaranteed to carry.
pub(crate) fn build(records: &[Comparison]) -> Report {
Report {
summary: summary(records),
@@ -110,10 +123,9 @@ fn verdict_stats(records: &[Comparison]) -> Vec {
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut notional: HashMap<&str, f64> = HashMap::new();
for record in records {
- let verdict = record.top.verdict.as_str();
+ let verdict = top(record).verdict.as_str();
*counts.entry(verdict).or_default() += 1;
- *notional.entry(verdict).or_default() += record
- .top
+ *notional.entry(verdict).or_default() += top(record)
.settled_value_usd
.unwrap_or(0.0);
}
@@ -136,26 +148,26 @@ fn verdict_stats(records: &[Comparison]) -> Vec {
fn savings(records: &[Comparison]) -> Savings {
let scored: Vec<&Comparison> = records
.iter()
- .filter(|r| r.top.is_scored())
+ .filter(|r| top(r).is_scored())
.collect();
// The savings-bps headline is over wins only — how much better Fynd was when it won, not
// diluted by the losses.
let mut win_bps: Vec = scored
.iter()
- .filter(|r| r.top.verdict == "win")
- .filter_map(|r| r.top.net_bps)
+ .filter(|r| top(r).verdict == "win")
+ .filter_map(|r| top(r).net_bps)
.collect();
Savings {
scored: scored.len(),
wins: scored
.iter()
- .filter(|r| r.top.verdict == "win")
+ .filter(|r| top(r).verdict == "win")
.count(),
median_win_bps: median(&mut win_bps),
won_usd: scored
.iter()
- .filter(|r| r.top.verdict == "win")
- .filter_map(|r| r.top.improvement_usd)
+ .filter(|r| top(r).verdict == "win")
+ .filter_map(|r| top(r).improvement_usd)
.sum(),
}
}
@@ -173,29 +185,29 @@ fn group_stats(records: &[Comparison], key: impl Fn(&Comparison) -> &String) ->
.map(|(name, group)| {
let mut net_bps: Vec = group
.iter()
- .filter(|r| r.top.is_scored())
- .filter_map(|r| r.top.net_bps)
+ .filter(|r| top(r).is_scored())
+ .filter_map(|r| top(r).net_bps)
.collect();
GroupStats {
name: name.clone(),
count: group.len(),
wins: group
.iter()
- .filter(|r| r.top.verdict == "win")
+ .filter(|r| top(r).verdict == "win")
.count(),
losses: group
.iter()
- .filter(|r| r.top.verdict == "loss")
+ .filter(|r| top(r).verdict == "loss")
.count(),
unsolved: group
.iter()
- .filter(|r| !r.top.is_served())
+ .filter(|r| !top(r).is_served())
.count(),
median_net_bps: median(&mut net_bps),
total_improvement_usd: group
.iter()
- .filter(|r| r.top.is_scored())
- .filter_map(|r| r.top.improvement_usd)
+ .filter(|r| top(r).is_scored())
+ .filter_map(|r| top(r).improvement_usd)
.sum(),
}
})
@@ -231,15 +243,15 @@ fn top_losses(records: &[Comparison]) -> Vec {
fn trade_rows(records: &[Comparison], verdict: &str) -> Vec {
records
.iter()
- .filter(|r| r.top.verdict == verdict)
+ .filter(|r| top(r).verdict == verdict)
.filter_map(|r| {
- r.top
+ top(r)
.improvement_usd
.map(|usd| TradeRow {
- settled_tx: r.settled_tx.clone(),
+ tx_hash: r.tx_hash.clone(),
venue: r.venue.clone(),
solver: r.solver.clone(),
- net_bps: r.top.net_bps,
+ net_bps: top(r).net_bps,
improvement_usd: usd,
})
})
@@ -250,14 +262,14 @@ fn unsolvable_tokens(records: &[Comparison]) -> Vec {
let mut counts: HashMap<&str, usize> = HashMap::new();
for record in records
.iter()
- .filter(|r| !r.top.is_served())
+ .filter(|r| !top(r).is_served())
{
- *counts
- .entry(record.token_in.as_str())
- .or_default() += 1;
- *counts
- .entry(record.token_out.as_str())
- .or_default() += 1;
+ if let Some(token_in) = record.token_in.as_deref() {
+ *counts.entry(token_in).or_default() += 1;
+ }
+ if let Some(token_out) = record.token_out.as_deref() {
+ *counts.entry(token_out).or_default() += 1;
+ }
}
let mut ranked: Vec = counts
.into_iter()
@@ -299,11 +311,12 @@ mod tests {
) -> Comparison {
serde_json::from_value(serde_json::json!({
"block": block,
- "settled_tx": format!("0x{block:064x}"),
+ "tx_hash": format!("0x{block:064x}"),
"venue": venue,
"solver": solver,
"token_in": "0xaaa",
"token_out": "0xbbb",
+ "status": "settled",
"top": {
"verdict": verdict,
"net_bps": bps,
diff --git a/tools/hindsight/src/report/html.rs b/tools/hindsight/src/report/html.rs
index ec69abc85..1f5717bb7 100644
--- a/tools/hindsight/src/report/html.rs
+++ b/tools/hindsight/src/report/html.rs
@@ -241,7 +241,7 @@ fn trades_section(title: &str, trades: &[TradeRow]) -> String {
table,
"| {} | {} | {} | \
{} | {} |
",
- escape(&short_hash(&trade.settled_tx)),
+ escape(&short_hash(&trade.tx_hash)),
escape(&trade.venue),
escape(&trade.solver),
fmt_bps(trade.net_bps),
@@ -421,13 +421,15 @@ mod tests {
fn sample_report() -> Report {
let records: Vec = vec![
serde_json::json!({
- "block": 1, "settled_tx": "0xabc0000000000000000000000000000000000000000000000000000000000001",
+ "block": 1, "tx_hash": "0xabc0000000000000000000000000000000000000000000000000000000000001",
"venue": "relay", "solver": "1inch", "token_in": "0xaaa", "token_out": "0xbbb",
+ "status": "settled",
"top": {"verdict": "win", "net_bps": 20.0, "improvement_usd": 12.0, "settled_value_usd": 1000.0}
}),
serde_json::json!({
- "block": 2, "settled_tx": "0xdef0000000000000000000000000000000000000000000000000000000000002",
+ "block": 2, "tx_hash": "0xdef0000000000000000000000000000000000000000000000000000000000002",
"venue": "relay", "solver": "0x", "token_in": "0xccc", "token_out": "0xddd",
+ "status": "settled",
"top": {"verdict": "unsolvable", "net_bps": null, "improvement_usd": null, "settled_value_usd": 50.0}
}),
]
diff --git a/tools/hindsight/src/report/mod.rs b/tools/hindsight/src/report/mod.rs
index 174d80240..996dd5e5a 100644
--- a/tools/hindsight/src/report/mod.rs
+++ b/tools/hindsight/src/report/mod.rs
@@ -41,7 +41,13 @@ 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)?;
+ // Savings and win-rate only mean something for a trade that actually delivered an output —
+ // filter reverted trades out explicitly rather than relying on their absent `top` state.
+ let settled: Vec = all
+ .into_iter()
+ .filter(|record| record.status == "settled")
+ .collect();
+ let records = filter_by_venue(settled, &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());
@@ -85,7 +91,8 @@ fn filter_by_venue(records: Vec, venues: &[String]) -> anyhow::Resul
Ok(filtered)
}
-/// Read and parse every `.jsonl` file in `dir` into comparison records. Malformed lines are
+/// Read and parse every `comparisons-*.jsonl` file in `dir` into comparison records — settled and
+/// reverted trades alike; `run` filters to settled before aggregating. Malformed lines are
/// counted and skipped rather than failing the whole report — a truncated final line from an
/// interrupted run should not lose the rest of the data.
fn read_comparisons(dir: &Path) -> anyhow::Result> {
@@ -95,12 +102,15 @@ fn read_comparisons(dir: &Path) -> anyhow::Result> {
.map(|entry| entry.path())
.filter(|path| {
path.extension()
- .is_some_and(|ext| ext == "jsonl")
+ .is_some_and(|ext| ext == "jsonl") &&
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with("comparisons-"))
})
.collect();
files.sort();
if files.is_empty() {
- bail!("no .jsonl files in {}", dir.display());
+ bail!("no comparisons-*.jsonl files in {}", dir.display());
}
let mut records = Vec::new();
@@ -142,8 +152,9 @@ mod tests {
fn line(block: u64, verdict: &str) -> String {
serde_json::json!({
- "block": block, "settled_tx": format!("0x{block:064x}"),
+ "block": block, "tx_hash": format!("0x{block:064x}"),
"venue": "relay", "solver": "1inch", "token_in": "0xaaa", "token_out": "0xbbb",
+ "status": "settled",
"top": {"verdict": verdict, "net_bps": 1.0, "improvement_usd": 1.0, "settled_value_usd": 1.0},
})
.to_string()
@@ -171,6 +182,22 @@ mod tests {
fs::remove_dir_all(&dir).unwrap();
}
+ #[test]
+ fn test_ignores_non_comparisons_jsonl_files() {
+ // Only `comparisons-*.jsonl` files are read; anything else in the directory (an old
+ // stream from a prior version, a stray file) is left alone rather than fed to the parser.
+ let dir = write_dir(
+ "with-other-files",
+ &[
+ ("comparisons-2026-07-20.jsonl", &format!("{}\n", line(1, "win"))),
+ ("other-2026-07-20.jsonl", "{\"block\":1}\n"),
+ ],
+ );
+ let records = read_comparisons(&dir).unwrap();
+ assert_eq!(records.len(), 1);
+ fs::remove_dir_all(&dir).unwrap();
+ }
+
#[test]
fn test_empty_dir_is_an_error() {
let dir = write_dir("empty", &[]);
@@ -180,8 +207,8 @@ mod tests {
fn venue_record(venue: &str) -> Comparison {
serde_json::from_value(serde_json::json!({
- "block": 1, "settled_tx": "0x1", "venue": venue, "solver": "1inch",
- "token_in": "0xaaa", "token_out": "0xbbb",
+ "block": 1, "tx_hash": "0x1", "venue": venue, "solver": "1inch",
+ "token_in": "0xaaa", "token_out": "0xbbb", "status": "settled",
"top": {"verdict": "win", "net_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..4440b6134 100644
--- a/tools/hindsight/src/report/record.rs
+++ b/tools/hindsight/src/report/record.rs
@@ -9,16 +9,25 @@
use serde::Deserialize;
/// One re-solved trade: the settled trade's identity plus Fynd's result at each block state.
+/// Carries both settled and reverted trades (told apart by `status`) — the report filters to
+/// `status == "settled"` before aggregating (see `report::run`), since Allium-style savings and
+/// win-rate views only make sense for a trade that actually delivered an output.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct Comparison {
pub block: u64,
- pub settled_tx: String,
+ pub tx_hash: String,
pub venue: String,
pub solver: String,
- pub token_in: String,
- pub token_out: String,
+ /// `None` only for a reverted trade with no known terms — always present once filtered to
+ /// `status == "settled"`.
+ pub token_in: Option,
+ pub token_out: Option,
+ pub status: String,
/// Optimistic state (N-1); the report's headline, matching the monitor's headline verdict.
- pub top: State,
+ /// `None` only for a reverted trade with no known terms — a settled trade always carries one
+ /// (see `DecodedTrade`'s settled/reverted invariant), so every record this module aggregates
+ /// (already filtered to `status == "settled"`) has one.
+ pub top: Option,
}
/// Fynd's result at one block state.
@@ -81,15 +90,16 @@ mod tests {
tx_hash: TxHash::repeat_byte(0x42),
block_number: 25_000_000,
tx_index: 0,
+ status: crate::decoder::TradeStatus::Settled,
venue: "relay".into(),
solver: "1inch".into(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
sender: Address::ZERO,
- token_in: weth,
- token_out: usdc,
- amount_in: U256::from(1_000u64),
- amount_out: U256::from(1_000_000_000u64), // settled 1000 USDC
+ token_in: Some(weth),
+ token_out: Some(usdc),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: Some(U256::from(1_000_000_000u64)), // settled 1000 USDC
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
@@ -110,9 +120,7 @@ mod tests {
let range = build_range(
&trade,
&prices,
- top,
- Outcome::Unsolvable("x".into()),
- &Outcome::Unsolvable("x".into()),
+ Some((top, Outcome::Unsolvable("x".into()), Outcome::Unsolvable("x".into()))),
);
let mut buf = Vec::new();
@@ -123,10 +131,12 @@ mod tests {
assert_eq!(record.block, 25_000_000);
assert_eq!(record.venue, "relay");
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.improvement_usd.unwrap() - 10.0).abs() < 1e-3);
- assert_eq!(record.token_out, format!("{usdc:#x}"));
+ assert_eq!(record.status, "settled");
+ let top = record.top.unwrap();
+ assert_eq!(top.verdict, "win");
+ assert!(top.is_scored());
+ assert!(top.net_bps.unwrap() > 0.0);
+ assert!((top.improvement_usd.unwrap() - 10.0).abs() < 1e-3);
+ assert_eq!(record.token_out, Some(format!("{usdc:#x}")));
}
}
diff --git a/tools/hindsight/src/resolve/compare.rs b/tools/hindsight/src/resolve/compare.rs
index 98deccae7..e098bb04f 100644
--- a/tools/hindsight/src/resolve/compare.rs
+++ b/tools/hindsight/src/resolve/compare.rs
@@ -14,7 +14,9 @@ fn to_biguint(amount: U256) -> BigUint {
}
/// Basis-point deltas of a Fynd quote against the settled amount (positive = Fynd better).
-#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
+/// `Deltas::default()` (both `None`) when there is nothing settled to compare against — an
+/// unsolved outcome, or a reverted trade.
+#[derive(Debug, Clone, Copy, Default, 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`.
@@ -26,10 +28,6 @@ pub(crate) struct Deltas {
pub net_bps: Option,
}
-impl Deltas {
- const NONE: Self = Self { raw_bps: None, net_bps: None };
-}
-
/// Slippage of the top-of-block route re-executed at back-of-block: how the route's output moved
/// between quote time (N-1) and execution time (N). Positive = the route produced more than
/// quoted — the surplus we would keep if we charged positive slippage.
@@ -115,7 +113,7 @@ pub(crate) fn compare(
settled_net_gas: U256,
) -> Deltas {
let Outcome::Solved(solved) = outcome else {
- return Deltas::NONE;
+ return Deltas::default();
};
Deltas {
raw_bps: raw_bps_diff(&to_biguint(solved.amount_out), &to_biguint(settled_amount_out)),
@@ -141,6 +139,29 @@ pub(crate) fn verdict(outcome: &Outcome, deltas: &Deltas) -> Verdict {
}
}
+/// Judge a solved outcome against a floor (`min_amount_out`), independent of whether anything
+/// settled — the same judgment a reverted trade needs against its on-chain floor, and a settled
+/// trade can use too when a floor happens to be known (it cleared the floor by construction, but
+/// the margin is still informative). `(None, None)` when the state was not solved or no floor is
+/// known.
+///
+/// Returns `(fillable, margin_bps)`: `fillable` is whether the quoted output would have cleared
+/// the floor; `margin_bps` is the signed bps of `(quote - floor) / floor`, positive meaning the
+/// quote cleared it with room to spare. `margin_bps` is `None` when the floor is zero (no
+/// denominator to divide by) even though `fillable` still answers in that case, since any output
+/// clears a zero floor.
+pub(crate) fn floor_judgment(
+ outcome: &Outcome,
+ min_amount_out: Option,
+) -> (Option, Option) {
+ let (Outcome::Solved(solved), Some(floor)) = (outcome, min_amount_out) else {
+ return (None, None);
+ };
+ let fillable = solved.amount_out >= floor;
+ let margin_bps = raw_bps_diff(&to_biguint(solved.amount_out), &to_biguint(floor));
+ (Some(fillable), margin_bps)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -191,7 +212,7 @@ mod tests {
fn test_compare_unsolvable() {
let (settled, net) = gross(10_000);
let d = compare(&Outcome::Unsolvable("no route".into()), settled, net);
- assert_eq!(d, Deltas::NONE);
+ assert_eq!(d, Deltas::default());
}
#[test]
@@ -286,4 +307,51 @@ mod tests {
fn slippage_none_for_zero_quoted_output() {
assert_eq!(slippage(&solved(0, 0), &solved(10, 10)), None);
}
+
+ #[test]
+ fn test_floor_judgment_exactly_at_floor() {
+ let (fillable, margin_bps) =
+ floor_judgment(&solved(10_000, 9_900), Some(U256::from(10_000u64)));
+ assert_eq!(fillable, Some(true));
+ assert_eq!(margin_bps, Some(0.0));
+ }
+
+ #[test]
+ fn test_floor_judgment_above_and_below_floor() {
+ let (fillable, margin_bps) =
+ floor_judgment(&solved(10_100, 10_000), Some(U256::from(10_000u64)));
+ assert_eq!(fillable, Some(true));
+ assert!(margin_bps.unwrap() > 0.0);
+
+ let (fillable, margin_bps) =
+ floor_judgment(&solved(9_900, 9_800), Some(U256::from(10_000u64)));
+ assert_eq!(fillable, Some(false));
+ assert!(margin_bps.unwrap() < 0.0);
+ }
+
+ #[test]
+ fn test_floor_judgment_unsolved() {
+ let (fillable, margin_bps) =
+ floor_judgment(&Outcome::Unsolvable("no route".into()), Some(U256::from(10_000u64)));
+ assert_eq!(fillable, None);
+ assert_eq!(margin_bps, None);
+ }
+
+ #[test]
+ fn test_floor_judgment_no_floor_known() {
+ // A solved outcome with no floor to judge against (e.g. a settled trade whose calldata
+ // declared no min_amount_out) — neither field applies.
+ let (fillable, margin_bps) = floor_judgment(&solved(10_000, 9_900), None);
+ assert_eq!(fillable, None);
+ assert_eq!(margin_bps, None);
+ }
+
+ #[test]
+ fn test_floor_judgment_zero_floor() {
+ // Any non-negative quote clears a zero floor: fillable is Some(true), but there is no
+ // denominator to divide by for the margin.
+ let (fillable, margin_bps) = floor_judgment(&solved(10_000, 9_900), Some(U256::ZERO));
+ assert_eq!(fillable, Some(true));
+ assert_eq!(margin_bps, None);
+ }
}
diff --git a/tools/hindsight/src/resolve/jsonl.rs b/tools/hindsight/src/resolve/jsonl.rs
index 1a1e53383..957134734 100644
--- a/tools/hindsight/src/resolve/jsonl.rs
+++ b/tools/hindsight/src/resolve/jsonl.rs
@@ -16,13 +16,20 @@ use fynd_core::types::{OrderQuote, Swap, Transaction};
use tracing::{info, warn};
use crate::{
+ decoder::{RevertCause, TradeStatus},
resolve::{render_route, Outcome, RangeComparison, StateResult},
+ telemetry::revert_cause_label,
usd::Prices,
};
-/// Append-only comparisons writer that rotates to a new file at each UTC day boundary —
-/// `comparisons-YYYY-MM-DD.jsonl` inside its directory — so an external sync job (e.g. an S3
-/// upload `CronJob`) ships closed daily files instead of re-shipping one ever-growing one.
+/// The rotating file's name prefix — every trade, settled or reverted, writes here (see
+/// `RangeComparison`'s `status` field); there is only one stream.
+const PREFIX: &str = "comparisons";
+
+/// Append-only writer that rotates to a new file at each UTC day boundary — `comparisons-YYYY-MM
+/// -DD.jsonl` inside its directory — so an external sync job (e.g. an S3 upload `CronJob`) ships
+/// closed daily files instead of re-shipping one ever-growing one. `monitor` opens one of these in
+/// its `--comparisons-dir`.
pub(crate) struct RotatingWriter {
dir: PathBuf,
date: String,
@@ -30,7 +37,8 @@ pub(crate) struct RotatingWriter {
}
impl RotatingWriter {
- /// Open today's file inside `dir` for appending, creating the directory if needed.
+ /// Open today's `comparisons-*.jsonl` file inside `dir` for appending, creating the directory
+ /// if needed.
pub(crate) fn open(dir: impl Into) -> anyhow::Result {
let dir = dir.into();
std::fs::create_dir_all(&dir)
@@ -59,23 +67,23 @@ impl RotatingWriter {
return;
}
if let Err(e) = self.writer.flush() {
- warn!(error = %e, "failed to flush comparisons file before rotation");
+ warn!(error = %e, "failed to flush jsonl file before rotation");
}
match open_dated(&self.dir, &date) {
Ok(writer) => {
- info!(path = %dated_path(&self.dir, &date).display(), "rotated comparisons file");
+ info!(path = %dated_path(&self.dir, &date).display(), "rotated jsonl file");
self.writer = writer;
self.date = date;
}
Err(e) => {
- warn!(error = %e, "failed to rotate comparisons file; keeping the previous day's");
+ warn!(error = %e, "failed to rotate jsonl file; keeping the previous day's");
}
}
}
}
fn dated_path(dir: &Path, date: &str) -> PathBuf {
- dir.join(format!("comparisons-{date}.jsonl"))
+ dir.join(format!("{PREFIX}-{date}.jsonl"))
}
fn open_dated(dir: &Path, date: &str) -> anyhow::Result> {
@@ -139,61 +147,87 @@ pub(crate) fn write_comparisons(
}
}
-/// Build the JSON record for one re-solved trade: block, settled tx, decoded amounts, a `top`
-/// and `back` state (each with its verdict, bps, USD delta, and slim route/calldata or unsolvable
-/// reason), and the top route's slippage between the two states. Top is valued at N-1 prices,
-/// back (and the slippage) at N prices, matching the state each was produced at.
+/// A record's flat status fields: `status` ("settled"/"reverted"), and — for a revert — a bounded
+/// `cause` label (matching `telemetry::revert_cause_label`) plus its free-text `cause_detail`.
+/// Kept flat (not the nested `{"kind":...}` shape `RevertCause` itself serializes to) so a jq
+/// pass can filter on `status`/`cause` directly, matching the old `reverts-*.jsonl` ergonomics
+/// now that both streams are one.
+fn status_fields(status: &TradeStatus) -> (&'static str, Option<&'static str>, Option<&str>) {
+ match status {
+ TradeStatus::Settled => ("settled", None, None),
+ TradeStatus::Reverted { cause } => {
+ let detail = match cause {
+ RevertCause::Other(detail) => Some(detail.as_str()),
+ RevertCause::SlippageFloor | RevertCause::OutOfGas => None,
+ };
+ ("reverted", Some(revert_cause_label(cause)), detail)
+ }
+ }
+}
+
+/// Build the JSON record for one re-solved trade — settled or reverted, told apart by `status`:
+/// block, tx, decoded amounts, a `top` and `back` state (each with its verdict, bps, fillable/
+/// margin judgment, and slim route/calldata or unsolvable reason), and the top route's slippage
+/// between the two states. `top`/`back`/`slippage` are `null` when the trade's terms were
+/// unknown — there was nothing to solve. Top is valued at N-1 prices, back (and the slippage) at
+/// N prices, matching the state each was produced at.
fn comparison_record(
range: &RangeComparison,
prices_top: &Prices,
prices_back: &Prices,
) -> serde_json::Value {
+ let (status, cause, cause_detail) = status_fields(&range.status);
// Signed in both directions; the positive records are the "revenue if we charged positive
// slippage" view, filtered downstream.
let slippage = range.slippage.map(|slippage| {
- serde_json::json!({
- "bps": slippage.bps,
- "usd": prices_back.savings_usd(
- range.token_out,
+ let usd = range.token_out.and_then(|token_out| {
+ prices_back.savings_usd(
+ token_out,
slippage.reexecuted_amount_out,
slippage.quoted_amount_out,
- ),
- })
+ )
+ });
+ serde_json::json!({ "bps": slippage.bps, "usd": usd })
});
serde_json::json!({
"block": range.block_number,
"tx_index": range.tx_index,
- "settled_tx": range.tx_hash,
+ "tx_hash": range.tx_hash,
+ "status": status,
+ "cause": cause,
+ "cause_detail": cause_detail,
"venue": range.venue,
"solver": range.solver,
"solver_source": range.solver_source,
"decoder": range.decoder,
- "token_in": format!("{:#x}", range.token_in),
- "token_out": format!("{:#x}", range.token_out),
- "amount_in": range.amount_in.to_string(),
- "settled_amount_out": range.settled_amount_out.to_string(),
- "settled_amount_out_net_gas": range.settled_amount_out_net_gas.to_string(),
+ "sender": format!("{:#x}", range.sender),
+ "token_in": range.token_in.map(|token| format!("{token:#x}")),
+ "token_out": range.token_out.map(|token| format!("{token:#x}")),
+ "amount_in": range.amount_in.map(|amount| amount.to_string()),
+ "settled_amount_out": range.settled_amount_out.map(|amount| amount.to_string()),
+ "settled_amount_out_net_gas": range.settled_amount_out_net_gas.map(|amount| amount.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,
"sandwich": range.sandwich,
"slippage": slippage,
- "top": state_record(&range.top, range, prices_top),
- "back": state_record(&range.back, range, prices_back),
+ "top": range.top.as_ref().map(|top| state_record(top, range, prices_top)),
+ "back": range.back.as_ref().map(|back| state_record(back, range, prices_back)),
})
}
-/// JSON for one block-state of an improvement: verdict, bps, Fynd amounts, the USD improvement
-/// (gross Fynd output minus the gross settled output, valued at `prices` — the same basis as the
-/// headline verdict), the winning route's algorithm and rendered path, and the slim quote.
-/// `settled_value_usd` stays gross — it is the trade's notional, not a comparison.
+/// JSON for one block-state of a trade: verdict, bps, Fynd amounts, the USD improvement (gross
+/// Fynd output minus the gross settled output, valued at `prices` — the same basis as the
+/// headline verdict), the winning route's algorithm and rendered path, the slim quote, and the
+/// fillable/margin judgment against `min_amount_out` (present whenever a floor is known, settled
+/// or reverted). `settled_value_usd`/`improvement_usd` are `null` for a reverted trade — nothing
+/// settled to value or improve on.
fn state_record(
state: &StateResult,
range: &RangeComparison,
prices: &Prices,
) -> serde_json::Value {
- let token_out = range.token_out;
let solved = match &state.outcome {
Outcome::Solved(solved) => Some(solved),
Outcome::Partial(_) | Outcome::Unsolvable(_) => None,
@@ -204,13 +238,25 @@ fn state_record(
Outcome::Unsolvable(reason) | Outcome::Partial(reason) => Some(reason.as_str()),
Outcome::Solved(_) => None,
};
- let improvement_usd =
- solved.and_then(|s| prices.savings_usd(token_out, s.amount_out, range.settled_amount_out));
- let fynd_value_usd = solved.and_then(|s| prices.value_usd(token_out, s.amount_out));
+ let token_and_settled = range
+ .token_out
+ .zip(range.settled_amount_out);
+ let improvement_usd = solved.and_then(|s| {
+ token_and_settled
+ .and_then(|(token_out, settled)| prices.savings_usd(token_out, s.amount_out, settled))
+ });
+ let fynd_value_usd = range
+ .token_out
+ .zip(solved)
+ .and_then(|(token_out, s)| prices.value_usd(token_out, s.amount_out));
+ let settled_value_usd =
+ token_and_settled.and_then(|(token_out, settled)| prices.value_usd(token_out, settled));
serde_json::json!({
"verdict": state.verdict,
"net_bps": state.deltas.net_bps,
"raw_bps": state.deltas.raw_bps,
+ "fillable": state.fillable,
+ "margin_bps": state.margin_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()),
"gas_estimate": solved.map(|s| s.gas_estimate.to_string()),
@@ -220,7 +266,7 @@ fn state_record(
"route": solved.map(|s| s.solved_route.as_deref().map(render_route).unwrap_or_default()),
"improvement_usd": improvement_usd,
"fynd_value_usd": fynd_value_usd,
- "settled_value_usd": prices.value_usd(token_out, range.settled_amount_out),
+ "settled_value_usd": settled_value_usd,
"unsolvable_reason": unsolvable_reason,
"quote": solved
.and_then(|s| s.quote_json.as_deref())
@@ -331,15 +377,16 @@ mod tests {
tx_hash: TxHash::default(),
block_number: 25_480_207,
tx_index: 3,
+ status: TradeStatus::Settled,
venue: "relay".into(),
solver: "kyberswap".into(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
- sender: Address::ZERO,
- token_in: Address::ZERO,
- token_out: Address::repeat_byte(0x22),
- amount_in: U256::from(1_000u64),
- amount_out: U256::from(69_996_280_564u64),
+ sender: Address::repeat_byte(0x77),
+ token_in: Some(Address::ZERO),
+ token_out: Some(Address::repeat_byte(0x22)),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: Some(U256::from(69_996_280_564u64)),
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
@@ -351,12 +398,16 @@ mod tests {
let range = build_range(
&trade,
&empty_prices(),
- Outcome::Unsolvable("x".into()),
- Outcome::Unsolvable("x".into()),
- &Outcome::Unsolvable("x".into()),
+ Some((
+ Outcome::Unsolvable("x".into()),
+ Outcome::Unsolvable("x".into()),
+ Outcome::Unsolvable("x".into()),
+ )),
);
let rec = comparison_record(&range, &empty_prices(), &empty_prices());
assert_eq!(rec.pointer("/tx_index").unwrap(), 3);
+ assert_eq!(rec.pointer("/sender").unwrap(), &format!("{:#x}", Address::repeat_byte(0x77)));
+ assert_eq!(rec.pointer("/status").unwrap(), "settled");
assert_eq!(
rec.pointer("/quoted_amount_out")
.unwrap(),
@@ -409,15 +460,16 @@ mod tests {
tx_hash: TxHash::default(),
block_number: 25_000_000,
tx_index: 0,
+ status: TradeStatus::Settled,
venue: "relay".into(),
solver: "1inch".into(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
sender: Address::ZERO,
- token_in: weth,
- token_out: usdc,
- amount_in: U256::from(1_000u64),
- amount_out: U256::from(1_000_000_000u64), // settled 1000 USDC
+ token_in: Some(weth),
+ token_out: Some(usdc),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: Some(U256::from(1_000_000_000u64)), // settled 1000 USDC
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
@@ -456,7 +508,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, &prices, Some((top, back.clone(), back)));
comparison_record(&range, &prices, &prices)
}
@@ -533,15 +585,16 @@ mod tests {
tx_hash: TxHash::default(),
block_number: 25_000_000,
tx_index: 0,
+ status: TradeStatus::Settled,
venue: "relay".into(),
solver: "1inch".into(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
sender: Address::ZERO,
- token_in: Address::repeat_byte(0x11),
- token_out: Address::repeat_byte(0x22),
- amount_in: U256::from(1_000u64),
- amount_out: U256::from(1_000u64),
+ token_in: Some(Address::repeat_byte(0x11)),
+ token_out: Some(Address::repeat_byte(0x22)),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: Some(U256::from(1_000u64)),
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
@@ -554,9 +607,11 @@ mod tests {
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()),
+ Some((
+ 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()),
+ )),
);
let rec = comparison_record(&range, &empty_prices(), &empty_prices());
assert_eq!(rec.pointer("/top/verdict").unwrap(), "unsolvable");
@@ -592,15 +647,16 @@ mod tests {
tx_hash: TxHash::default(),
block_number: 25_000_000,
tx_index: 42,
+ status: TradeStatus::Settled,
venue: "relay".into(),
solver: "1inch".into(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
sender: Address::ZERO,
- token_in: Address::repeat_byte(0x11),
- token_out: Address::repeat_byte(0x22),
- amount_in: U256::from(1_000u64),
- amount_out: U256::from(1_000u64),
+ token_in: Some(Address::repeat_byte(0x11)),
+ token_out: Some(Address::repeat_byte(0x22)),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: Some(U256::from(1_000u64)),
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
@@ -627,8 +683,11 @@ 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,
+ &empty_prices(),
+ Some((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);
@@ -645,4 +704,148 @@ mod tests {
&format!("{:#x}", Address::repeat_byte(0x44))
);
}
+
+ fn revert_solved(amount_out: u64) -> Outcome {
+ Outcome::Solved(SolvedAmount {
+ amount_out: U256::from(amount_out),
+ amount_out_net_gas: U256::from(amount_out),
+ gas_estimate: U256::from(21_000),
+ algorithm: String::new(),
+ quote_json: None,
+ solved_route: None,
+ })
+ }
+
+ fn reverted_trade(cause: RevertCause) -> DecodedTrade {
+ DecodedTrade {
+ tx_hash: TxHash::repeat_byte(0x55),
+ block_number: 25_000_000,
+ tx_index: 9,
+ status: TradeStatus::Reverted { cause },
+ venue: "relay".into(),
+ solver: "fly".into(),
+ solver_source: AttributionSource::TraceMatch,
+ decoder: "reverted",
+ sender: Address::repeat_byte(0x99),
+ token_in: Some(Address::repeat_byte(0x11)),
+ token_out: Some(Address::repeat_byte(0x22)),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: None,
+ venue_fee_in: None,
+ venue_fee_out: None,
+ settled_gas: None,
+ min_amount_out: Some(U256::from(10_000u64)),
+ declared_quote: None,
+ quote_timestamp: None,
+ sandwich: None,
+ }
+ }
+
+ #[test]
+ fn test_reverted_record_slippage_floor_fillable_at_back() {
+ let trade = reverted_trade(RevertCause::SlippageFloor);
+ let range = build_range(
+ &trade,
+ &empty_prices(),
+ Some((revert_solved(9_800), revert_solved(10_200), revert_solved(10_200))),
+ );
+ let rec = comparison_record(&range, &empty_prices(), &empty_prices());
+
+ assert_eq!(rec.pointer("/tx_index").unwrap(), 9);
+ assert_eq!(rec.pointer("/tx_hash").unwrap(), &TxHash::repeat_byte(0x55).to_string());
+ // A reverted trade still records who sent it — the tx sender, since there is no netted
+ // flow to draw a different tracked party from.
+ assert_eq!(rec.pointer("/sender").unwrap(), &format!("{:#x}", Address::repeat_byte(0x99)));
+ assert_eq!(rec.pointer("/status").unwrap(), "reverted");
+ assert_eq!(rec.pointer("/cause").unwrap(), "slippage_floor");
+ assert!(rec
+ .pointer("/cause_detail")
+ .unwrap()
+ .is_null());
+ assert_eq!(rec.pointer("/min_amount_out").unwrap(), "10000");
+ // Nothing settled: settled-only fields are absent.
+ assert!(rec
+ .pointer("/settled_amount_out")
+ .unwrap()
+ .is_null());
+ assert_eq!(rec.pointer("/top/fillable").unwrap(), false);
+ assert_eq!(rec.pointer("/back/fillable").unwrap(), true);
+ assert!(
+ rec.pointer("/back/margin_bps")
+ .unwrap()
+ .as_f64()
+ .unwrap() >
+ 0.0
+ );
+ }
+
+ #[test]
+ fn test_reverted_record_other_cause_carries_detail() {
+ let trade = reverted_trade(RevertCause::Other("execution reverted".to_string()));
+ let range = build_range(
+ &trade,
+ &empty_prices(),
+ Some((
+ Outcome::Unsolvable("no route".into()),
+ Outcome::Unsolvable("no route".into()),
+ Outcome::Unsolvable("no route".into()),
+ )),
+ );
+ let rec = comparison_record(&range, &empty_prices(), &empty_prices());
+
+ assert_eq!(rec.pointer("/cause").unwrap(), "other");
+ assert_eq!(rec.pointer("/cause_detail").unwrap(), "execution reverted");
+ assert!(rec
+ .pointer("/top/fillable")
+ .unwrap()
+ .is_null());
+ }
+
+ #[test]
+ fn test_reverted_trade_with_unknown_terms_has_no_top_or_back() {
+ let mut trade = reverted_trade(RevertCause::Other("unknown revert".to_string()));
+ trade.token_in = None;
+ trade.token_out = None;
+ trade.amount_in = None;
+ trade.min_amount_out = None;
+ let range = build_range(&trade, &empty_prices(), None);
+ let rec = comparison_record(&range, &empty_prices(), &empty_prices());
+
+ assert_eq!(rec.pointer("/status").unwrap(), "reverted");
+ assert!(rec.pointer("/top").unwrap().is_null());
+ assert!(rec.pointer("/back").unwrap().is_null());
+ assert!(rec
+ .pointer("/token_in")
+ .unwrap()
+ .is_null());
+ }
+
+ #[test]
+ fn test_write_comparisons_appends_lines_for_settled_and_reverted() {
+ let settled_range = build_range(
+ &reverted_trade(RevertCause::OutOfGas),
+ &empty_prices(),
+ Some((Outcome::Unsolvable("x".into()), revert_solved(10_000), revert_solved(10_000))),
+ );
+ let mut buf: Vec = Vec::new();
+ write_comparisons(
+ &mut buf,
+ std::slice::from_ref(&settled_range),
+ &empty_prices(),
+ &empty_prices(),
+ );
+ write_comparisons(
+ &mut buf,
+ std::slice::from_ref(&settled_range),
+ &empty_prices(),
+ &empty_prices(),
+ );
+
+ let text = String::from_utf8(buf).unwrap();
+ assert_eq!(text.lines().count(), 2);
+ for line in text.lines() {
+ let value: serde_json::Value = serde_json::from_str(line).unwrap();
+ assert_eq!(value["cause"], "out_of_gas");
+ }
+ }
}
diff --git a/tools/hindsight/src/resolve/mod.rs b/tools/hindsight/src/resolve/mod.rs
index eaace080b..413b70380 100644
--- a/tools/hindsight/src/resolve/mod.rs
+++ b/tools/hindsight/src/resolve/mod.rs
@@ -22,7 +22,7 @@ use serde::Serialize;
use tycho_simulation::tycho_common::models::Address as CoreAddress;
use crate::{
- decoder::{AttributionSource, DecodedTrade, SandwichEvidence},
+ decoder::{AttributionSource, DecodedTrade, SandwichEvidence, TradeStatus},
usd::Prices,
};
@@ -207,39 +207,70 @@ pub(crate) enum Outcome {
#[derive(Debug, Clone, Serialize)]
pub(crate) struct StateResult {
pub outcome: Outcome,
+ /// Fynd vs the settled output. `Deltas::default()` (all `None`) when nothing settled to
+ /// compare against — a reverted trade.
pub deltas: Deltas,
pub verdict: Verdict,
+ /// Whether the quote cleared the trade's on-chain floor (`min_amount_out`), when one is
+ /// known. Computed for both settled and reverted trades — a settled trade cleared its floor
+ /// by construction, but the margin is still informative. `None` when the state was not
+ /// solved or no floor is known.
+ pub fillable: Option,
+ pub margin_bps: Option,
}
impl StateResult {
- fn new(outcome: Outcome, settled_amount_out: U256, settled_net_gas: U256) -> Self {
- let outcome = compare::served(outcome, settled_amount_out);
- let deltas = compare::compare(&outcome, settled_amount_out, settled_net_gas);
+ /// `settled` is `Some((gross, net_gas))` for a settled trade, `None` for a reverted one —
+ /// there is nothing settled to compare gross output against, so `deltas`/`verdict` fall back
+ /// to their unsolved-comparison values (`Deltas::default()`, `Verdict::Unsolvable`) even when
+ /// `outcome` itself solved; `fillable`/`margin_bps` are unaffected, since they judge the
+ /// outcome against `min_amount_out`, not against a settled amount.
+ fn new(outcome: Outcome, settled: Option<(U256, U256)>, min_amount_out: Option) -> Self {
+ let outcome = match settled {
+ Some((gross, _)) => compare::served(outcome, gross),
+ None => outcome,
+ };
+ let deltas = match settled {
+ Some((gross, net)) => compare::compare(&outcome, gross, net),
+ None => Deltas::default(),
+ };
let verdict = compare::verdict(&outcome, &deltas);
- Self { outcome, deltas, verdict }
+ let (fillable, margin_bps) = compare::floor_judgment(&outcome, min_amount_out);
+ Self { outcome, deltas, verdict, fillable, margin_bps }
}
}
-/// A trade re-solved at both block states, presented as a range.
+/// A trade re-solved at both block states, presented as a range — settled or reverted, told
+/// apart by `status`. A reverted trade's settled-only fields (`settled_amount_out` and friends)
+/// are simply absent; everything else about the record has the same shape.
#[derive(Debug, Clone, Serialize)]
pub(crate) struct RangeComparison {
pub tx_hash: TxHash,
pub block_number: u64,
pub tx_index: u64,
+ #[serde(flatten)]
+ pub status: TradeStatus,
pub venue: String,
pub solver: String,
/// The evidence tier the solver label came from (from the decoder).
pub solver_source: AttributionSource,
- /// Which decoder recovered the settled trade.
+ /// Which decoder recovered the trade.
pub decoder: &'static str,
- pub token_in: Address,
- pub token_out: Address,
- pub amount_in: U256,
- pub settled_amount_out: U256,
+ /// The trader, from the decoder: the netted flow's tracked party for a settled trade, or the
+ /// transaction sender for a reverted one (there is no netted flow to draw a different party
+ /// from). Lets a venue-filler fill (e.g. Relay's rotating filler) be segmented by its actual
+ /// trader without an on-chain lookup.
+ pub sender: Address,
+ /// `None` only for a reverted trade whose solver frame's calldata did not parse.
+ pub token_in: Option,
+ pub token_out: Option,
+ pub amount_in: Option,
+ /// `None` for a reverted trade: nothing was delivered.
+ pub settled_amount_out: Option,
/// 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,
+ /// token is unpriced. `None` for a reverted trade.
+ pub settled_amount_out_net_gas: Option,
/// 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
@@ -251,17 +282,18 @@ pub(crate) struct RangeComparison {
/// Unix timestamp of `declared_quote`, when the calldata carries one.
pub quote_timestamp: Option,
/// Evidence that a front-run and a back-run bracketed this trade (from the decoder). `None`
- /// when no bracket pair was found.
+ /// when no bracket pair was found, or the trade reverted.
pub sandwich: Option,
- /// Optimistic: solved at state N-1, before the block's swaps moved the pools.
- pub top: StateResult,
+ /// Optimistic: solved at state N-1, before the block's swaps moved the pools. `None` when
+ /// the trade's terms were unknown — there was nothing to solve.
+ pub top: Option,
/// Pessimistic: solved fresh at state N, after the block's swaps moved the pools — what
- /// routing at the block's end state would deliver.
- pub back: StateResult,
- /// Headline verdict — top-of-block (the optimistic default).
- pub verdict: Verdict,
+ /// routing at the block's end state would deliver. `None` alongside `top`.
+ pub back: Option,
+ /// Headline verdict — top-of-block (the optimistic default). `None` alongside `top`.
+ pub verdict: Option,
/// Slippage of the top route between quote time (N-1) and re-execution (N). `None` when the
- /// top was unsolved or the re-execution failed.
+ /// top was unsolved, the re-execution failed, or the trade's terms were unknown.
pub slippage: Option,
}
@@ -280,14 +312,16 @@ pub(crate) trait SteppingSolver {
async fn reexecute(&self, top: &SolvedAmount) -> Outcome;
}
-/// Build a `RangeComparison` from a trade's three outcomes: the top-of-block solve, the fresh
-/// back-of-block solve, and the top route's re-execution at back-of-block (which feeds only the
-/// `slippage` field).
+/// Build a `RangeComparison` from a trade and, when its terms were known, the three outcomes
+/// solving it produced: the top-of-block solve, the fresh back-of-block solve, and the top
+/// route's re-execution at back-of-block (which feeds only the `slippage` field). `None` when the
+/// trade's terms were unknown — there was nothing to solve, so `top`/`back`/`verdict`/`slippage`
+/// on the returned comparison are all `None` too, but the trade is still recorded.
///
/// 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.
+/// their own gas. Reverted trades have no settled output to deduct from — `settled` is `None`.
///
/// 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
@@ -298,40 +332,55 @@ pub(crate) trait SteppingSolver {
pub(crate) fn build_range(
trade: &DecodedTrade,
prices: &Prices,
- top: Outcome,
- back: Outcome,
- reexecuted: &Outcome,
+ solved: Option<(Outcome, Outcome, Outcome)>,
) -> RangeComparison {
- let settled_net_gas = trade
- .settled_gas
- .and_then(|gas| prices.gas_in_token(gas, trade.token_out))
- .map_or(trade.amount_out, |gas_out| trade.amount_out.saturating_sub(gas_out));
- // Computed from the raw outcomes: the coverage-miss reclassification below discards the
- // solved amounts the slippage is measured from.
- let slippage = compare::slippage(&top, reexecuted);
- let mut top = StateResult::new(top, trade.amount_out, settled_net_gas);
- let mut back = StateResult::new(back, trade.amount_out, settled_net_gas);
- if trade.sandwich.is_some() {
- for state in [&mut top, &mut back] {
- if let Outcome::Solved(_) = state.outcome {
- state.verdict = Verdict::Sandwiched;
+ let settled = trade.amount_out.map(|gross| {
+ let net_gas = trade
+ .settled_gas
+ .and_then(|gas| {
+ trade
+ .token_out
+ .and_then(|token| prices.gas_in_token(gas, token))
+ })
+ .map_or(gross, |gas_out| gross.saturating_sub(gas_out));
+ (gross, net_gas)
+ });
+
+ let (top, back, verdict, slippage) = match solved {
+ Some((top_outcome, back_outcome, reexecuted)) => {
+ // Computed from the raw outcomes: the coverage-miss reclassification below discards
+ // the solved amounts the slippage is measured from.
+ let slippage = compare::slippage(&top_outcome, &reexecuted);
+ let mut top = StateResult::new(top_outcome, settled, trade.min_amount_out);
+ let mut back = StateResult::new(back_outcome, settled, trade.min_amount_out);
+ if trade.sandwich.is_some() {
+ for state in [&mut top, &mut back] {
+ if let Outcome::Solved(_) = state.outcome {
+ state.verdict = Verdict::Sandwiched;
+ }
+ }
}
+ let verdict = top.verdict;
+ (Some(top), Some(back), Some(verdict), slippage)
}
- }
- let verdict = top.verdict;
+ None => (None, None, None, None),
+ };
+
RangeComparison {
tx_hash: trade.tx_hash,
block_number: trade.block_number,
tx_index: trade.tx_index,
+ status: trade.status.clone(),
venue: trade.venue.clone(),
solver: trade.solver.clone(),
solver_source: trade.solver_source,
decoder: trade.decoder,
+ sender: trade.sender,
token_in: trade.token_in,
token_out: trade.token_out,
amount_in: trade.amount_in,
- settled_amount_out: trade.amount_out,
- settled_amount_out_net_gas: settled_net_gas,
+ settled_amount_out: settled.map(|(gross, _)| gross),
+ settled_amount_out_net_gas: settled.map(|(_, net)| net),
settled_gas: trade.settled_gas,
min_amount_out: trade.min_amount_out,
declared_quote: trade.declared_quote,
@@ -345,10 +394,15 @@ pub(crate) fn build_range(
}
/// Re-solve every trade in a held block at top-of-block, advance to back-of-block, then measure
-/// each trade twice at the new state: re-execute its top route against the pools as the block
-/// left them (for the slippage), and solve it fresh (for the `back` comparison). Solving all
-/// trades at one state before advancing keeps each state's reads consistent and steps the chain
-/// only once per block.
+/// each again at the new state: its top route is re-executed against the pools as the block left
+/// them (for the slippage) and it is solved fresh (for the `back` comparison). Solving everything
+/// at one state before advancing keeps each state's reads consistent and steps the chain only
+/// once per block. Settled and reverted trades go through the same wave — a reverted trade whose
+/// solver calldata parsed into a `SwapIntent` is solved exactly like a settled one, judged against
+/// its `min_amount_out` floor instead of a settled amount.
+///
+/// A trade with unknown terms (a reverted trade whose solver calldata did not parse) is recorded
+/// but not solved — there is nothing to feed the solver.
pub(crate) async fn resolve_block_range(
solver: &S,
trades: &[DecodedTrade],
@@ -356,28 +410,41 @@ pub(crate) async fn resolve_block_range(
) -> anyhow::Result> {
let mut tops = Vec::with_capacity(trades.len());
for trade in trades {
- tops.push(
- solver
- .solve(trade.token_in, trade.token_out, trade.amount_in)
- .await,
- );
+ let top = match trade.terms() {
+ Some((token_in, token_out, amount_in)) => Some(
+ solver
+ .solve(token_in, token_out, amount_in)
+ .await,
+ ),
+ None => None,
+ };
+ tops.push(top);
}
solver.advance().await?;
let mut ranges = Vec::with_capacity(trades.len());
for (trade, top) in trades.iter().zip(tops) {
+ let Some(top) = top else {
+ ranges.push(build_range(trade, prices, None));
+ continue;
+ };
let reexecuted = match &top {
Outcome::Solved(solved) => solver.reexecute(solved).await,
Outcome::Partial(_) | Outcome::Unsolvable(_) => {
Outcome::Unsolvable("no top-of-block route to re-execute".to_string())
}
};
+ // `top` was solved from `trade.terms()`, so the terms are known here too.
+ let (token_in, token_out, amount_in) = trade
+ .terms()
+ .expect("a solved top implies known terms");
let back = solver
- .solve(trade.token_in, trade.token_out, trade.amount_in)
+ .solve(token_in, token_out, amount_in)
.await;
- ranges.push(build_range(trade, prices, top, back, &reexecuted));
+ ranges.push(build_range(trade, prices, Some((top, back, reexecuted))));
}
+
Ok(ranges)
}
@@ -397,15 +464,71 @@ mod tests {
tx_hash: TxHash::default(),
block_number: 21_000_000,
tx_index: 0,
+ status: TradeStatus::Settled,
venue: "relay".into(),
solver: "tycho".into(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
sender: Address::ZERO,
- token_in: Address::repeat_byte(0x11),
- token_out: Address::repeat_byte(0x22),
- amount_in: U256::from(1_000u64),
- amount_out: U256::from(settled),
+ token_in: Some(Address::repeat_byte(0x11)),
+ token_out: Some(Address::repeat_byte(0x22)),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: Some(U256::from(settled)),
+ venue_fee_in: None,
+ venue_fee_out: None,
+ settled_gas: None,
+ min_amount_out: None,
+ declared_quote: None,
+ quote_timestamp: None,
+ sandwich: None,
+ }
+ }
+
+ /// A reverted trade whose solver calldata parsed into a `SwapIntent` — carries terms and a
+ /// floor (`min_amount_out`) but no settled output.
+ fn reverted_trade(cause: crate::decoder::RevertCause, min_amount_out: u64) -> DecodedTrade {
+ DecodedTrade {
+ tx_hash: TxHash::repeat_byte(0x02),
+ block_number: 21_000_000,
+ tx_index: 1,
+ status: TradeStatus::Reverted { cause },
+ venue: "relay".into(),
+ solver: "fly".into(),
+ solver_source: AttributionSource::TraceMatch,
+ decoder: "reverted",
+ sender: Address::ZERO,
+ token_in: Some(Address::repeat_byte(0x11)),
+ token_out: Some(Address::repeat_byte(0x22)),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: None,
+ venue_fee_in: None,
+ venue_fee_out: None,
+ settled_gas: None,
+ min_amount_out: Some(U256::from(min_amount_out)),
+ declared_quote: None,
+ quote_timestamp: None,
+ sandwich: None,
+ }
+ }
+
+ /// A reverted trade whose solver calldata did not parse — no terms, nothing to solve.
+ fn reverted_trade_without_terms() -> DecodedTrade {
+ DecodedTrade {
+ tx_hash: TxHash::repeat_byte(0x03),
+ block_number: 21_000_000,
+ tx_index: 2,
+ status: TradeStatus::Reverted {
+ cause: crate::decoder::RevertCause::Other("unknown revert".to_string()),
+ },
+ venue: "relay".into(),
+ solver: "relay".into(),
+ solver_source: AttributionSource::Fallback,
+ decoder: "reverted",
+ sender: Address::ZERO,
+ token_in: None,
+ token_out: None,
+ amount_in: None,
+ amount_out: None,
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
@@ -470,12 +593,12 @@ mod tests {
let range = build_range(
&trade(10_000),
&empty_prices(),
- solved(10_200, 10_100),
- solved(10_010, 9_990),
- &solved(10_010, 9_990),
+ Some((solved(10_200, 10_100), solved(10_010, 9_990), solved(10_010, 9_990))),
);
- assert_eq!(range.verdict, Verdict::Win); // top is the headline
- assert!(range.top.deltas.raw_bps.unwrap() > range.back.deltas.raw_bps.unwrap());
+ assert_eq!(range.verdict, Some(Verdict::Win)); // top is the headline
+ let top = range.top.unwrap();
+ let back = range.back.unwrap();
+ assert!(top.deltas.raw_bps.unwrap() > back.deltas.raw_bps.unwrap());
}
#[test]
@@ -484,13 +607,12 @@ mod tests {
let range = build_range(
&trade(10_000),
&empty_prices(),
- solved(1_000, 990),
- solved(1_000, 990),
- &solved(1_000, 990),
+ Some((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!(matches!(range.top.outcome, Outcome::Partial(_)));
+ assert_eq!(range.verdict, Some(Verdict::CoverageMiss));
+ let top = range.top.unwrap();
+ assert_eq!(top.deltas, Deltas { raw_bps: None, net_bps: None });
+ assert!(matches!(top.outcome, Outcome::Partial(_)));
}
#[test]
@@ -505,17 +627,17 @@ mod tests {
let range = build_range(
&sandwiched,
&empty_prices(),
- solved(10_200, 10_100),
- solved(9_800, 9_700),
- &solved(9_800, 9_700),
+ Some((solved(10_200, 10_100), solved(9_800, 9_700), solved(9_800, 9_700))),
);
- assert_eq!(range.verdict, Verdict::Sandwiched);
- assert_eq!(range.top.verdict, Verdict::Sandwiched);
- assert_eq!(range.back.verdict, Verdict::Sandwiched);
+ assert_eq!(range.verdict, Some(Verdict::Sandwiched));
+ let top = range.top.unwrap();
+ let back = range.back.unwrap();
+ assert_eq!(top.verdict, Verdict::Sandwiched);
+ assert_eq!(back.verdict, Verdict::Sandwiched);
// Deltas are unaffected by the override: still computed for offline analysis.
- assert!(range.top.deltas.raw_bps.unwrap() > 0.0);
- assert!(range.back.deltas.raw_bps.unwrap() < 0.0);
+ assert!(top.deltas.raw_bps.unwrap() > 0.0);
+ assert!(back.deltas.raw_bps.unwrap() < 0.0);
}
#[test]
@@ -532,14 +654,16 @@ 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()),
+ Some((
+ solved(10_200, 10_100),
+ Outcome::Unsolvable("missing token in Tycho".into()),
+ Outcome::Unsolvable("re-execution failed".into()),
+ )),
);
- assert_eq!(range.top.verdict, Verdict::Sandwiched);
- assert_eq!(range.back.verdict, Verdict::Unsolvable);
- assert_eq!(range.verdict, Verdict::Sandwiched); // headline follows top
+ assert_eq!(range.top.unwrap().verdict, Verdict::Sandwiched);
+ assert_eq!(range.back.unwrap().verdict, Verdict::Unsolvable);
+ assert_eq!(range.verdict, Some(Verdict::Sandwiched)); // headline follows top
}
#[test]
@@ -549,18 +673,16 @@ mod tests {
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);
+ prices.insert(with_gas.token_out.unwrap(), 2.0);
let range = build_range(
&with_gas,
&prices,
- solved(10_050, 9_990),
- solved(10_050, 9_990),
- &solved(10_050, 9_990),
+ Some((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);
+ assert_eq!(range.settled_amount_out_net_gas, Some(U256::from(9_800u64)));
+ assert_eq!(range.settled_amount_out, Some(U256::from(10_000u64)));
+ assert_eq!(range.verdict, Some(Verdict::Win));
}
#[test]
@@ -573,12 +695,42 @@ mod tests {
let range = build_range(
&with_gas,
&empty_prices(),
- solved(10_050, 9_990),
- solved(10_050, 9_990),
- &solved(10_050, 9_990),
+ Some((solved(10_050, 9_990), solved(10_050, 9_990), solved(10_050, 9_990))),
+ );
+ assert_eq!(range.settled_amount_out_net_gas, Some(U256::from(10_000u64)));
+ assert_eq!(range.verdict, Some(Verdict::Win));
+ }
+
+ #[test]
+ fn test_build_range_unknown_terms_records_without_solving() {
+ // A reverted trade whose solver calldata did not parse: nothing to solve, but it is
+ // still recorded, tagged by its cause.
+ let range = build_range(&reverted_trade_without_terms(), &empty_prices(), None);
+ assert!(matches!(range.status, TradeStatus::Reverted { .. }));
+ assert!(range.token_in.is_none());
+ assert!(range.top.is_none());
+ assert!(range.back.is_none());
+ assert_eq!(range.verdict, None);
+ assert_eq!(range.slippage, None);
+ }
+
+ #[test]
+ fn test_build_range_reverted_trade_judged_against_floor() {
+ // A reverted trade with known terms and a floor: no settled amount to compare against
+ // (deltas/verdict stay at their unsolved defaults), but fillable/margin_bps still judge
+ // the quote against min_amount_out.
+ let range = build_range(
+ &reverted_trade(crate::decoder::RevertCause::SlippageFloor, 10_000),
+ &empty_prices(),
+ Some((solved(9_800, 9_700), solved(10_100, 10_000), solved(10_100, 10_000))),
);
- assert_eq!(range.settled_amount_out_net_gas, U256::from(10_000u64));
- assert_eq!(range.verdict, Verdict::Win);
+ assert!(range.settled_amount_out.is_none());
+ let top = range.top.unwrap();
+ let back = range.back.unwrap();
+ assert_eq!(top.deltas, Deltas { raw_bps: None, net_bps: None });
+ assert_eq!(top.fillable, Some(false));
+ assert_eq!(back.fillable, Some(true));
+ assert!(back.margin_bps.unwrap() > 0.0);
}
#[tokio::test]
@@ -598,9 +750,11 @@ mod tests {
assert_eq!(ranges.len(), 2);
for range in &ranges {
- assert_eq!(range.top.verdict, Verdict::Win);
- assert_eq!(range.back.verdict, Verdict::Loss);
- assert!(range.top.deltas.raw_bps.unwrap() > range.back.deltas.raw_bps.unwrap());
+ let top = range.top.as_ref().unwrap();
+ let back = range.back.as_ref().unwrap();
+ assert_eq!(top.verdict, Verdict::Win);
+ assert_eq!(back.verdict, Verdict::Loss);
+ assert!(top.deltas.raw_bps.unwrap() > back.deltas.raw_bps.unwrap());
let slippage = range.slippage.unwrap();
assert!(slippage.bps < 0.0, "re-execution below quote must be negative slippage");
assert_eq!(slippage.quoted_amount_out, U256::from(10_200u64));
@@ -623,11 +777,48 @@ mod tests {
.await
.unwrap();
- assert_eq!(ranges[0].top.verdict, Verdict::Unsolvable);
- assert_eq!(ranges[0].back.verdict, Verdict::Win);
+ assert_eq!(ranges[0].top.as_ref().unwrap().verdict, Verdict::Unsolvable);
+ assert_eq!(ranges[0].back.as_ref().unwrap().verdict, Verdict::Win);
assert_eq!(ranges[0].slippage, None);
}
+ #[tokio::test]
+ async fn resolve_block_range_solves_reverts_and_skips_unknown_terms() {
+ // The floor is 10_000: unfillable at top, fillable at back — the shape a sequencer that
+ // fills against fresher state would avoid. The intent-less revert has no terms, so it is
+ // recorded but never reaches the solver.
+ let solver = MockStepping {
+ advanced: std::sync::atomic::AtomicBool::new(false),
+ top: solved(9_800, 9_700),
+ back: solved(10_100, 10_000),
+ reexecuted: solved(10_100, 10_000),
+ };
+ let trades = [
+ reverted_trade(crate::decoder::RevertCause::SlippageFloor, 10_000),
+ reverted_trade_without_terms(),
+ ];
+ let ranges = resolve_block_range(&solver, &trades, &empty_prices())
+ .await
+ .unwrap();
+
+ assert_eq!(ranges.len(), 2);
+ assert!(ranges[1].top.is_none(), "the terms-less revert must not be solved");
+
+ let comparison = &ranges[0];
+ assert_eq!(comparison.min_amount_out, Some(U256::from(10_000u64)));
+ assert_eq!(
+ comparison
+ .top
+ .as_ref()
+ .unwrap()
+ .fillable,
+ Some(false)
+ );
+ let back = comparison.back.as_ref().unwrap();
+ assert_eq!(back.fillable, Some(true));
+ assert!(back.margin_bps.unwrap() > 0.0);
+ }
+
#[test]
fn build_range_positive_slippage_from_raw_outcomes() {
// The route re-executed to more than quoted: the surplus we could charge. The fresh
@@ -635,9 +826,7 @@ mod tests {
let range = build_range(
&trade(10_000),
&empty_prices(),
- solved(10_000, 9_900),
- solved(10_500, 10_400),
- &solved(10_050, 9_950),
+ Some((solved(10_000, 9_900), solved(10_500, 10_400), solved(10_050, 9_950))),
);
let slippage = range.slippage.unwrap();
assert!((slippage.bps - 50.0).abs() < 0.01, "expected +50 bps, got {}", slippage.bps);
@@ -650,11 +839,13 @@ mod tests {
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),
+ Some((
+ solved(10_000, 9_900),
+ Outcome::Unsolvable("no route at back-of-block".into()),
+ solved(10_050, 9_950),
+ )),
);
- assert_eq!(range.back.verdict, Verdict::Unsolvable);
+ assert_eq!(range.back.unwrap().verdict, Verdict::Unsolvable);
let slippage = range.slippage.unwrap();
assert!((slippage.bps - 50.0).abs() < 0.01, "expected +50 bps, got {}", slippage.bps);
}
@@ -667,11 +858,9 @@ mod tests {
let range = build_range(
&trade(10_000),
&empty_prices(),
- solved(1_000, 990),
- solved(1_010, 1_000),
- &solved(1_010, 1_000),
+ Some((solved(1_000, 990), solved(1_010, 1_000), solved(1_010, 1_000))),
);
- assert_eq!(range.verdict, Verdict::CoverageMiss);
+ assert_eq!(range.verdict, Some(Verdict::CoverageMiss));
let slippage = range.slippage.unwrap();
assert!((slippage.bps - 100.0).abs() < 0.01, "expected +100 bps, got {}", slippage.bps);
}
diff --git a/tools/hindsight/src/resolve/monitor.rs b/tools/hindsight/src/resolve/monitor.rs
index b09463e34..c8258a718 100644
--- a/tools/hindsight/src/resolve/monitor.rs
+++ b/tools/hindsight/src/resolve/monitor.rs
@@ -33,7 +33,7 @@ use tycho_simulation::tycho_common::models::{Address as CoreAddress, Chain};
use crate::{
decoder::{DecodedTrade, Decoder, Registry},
provider_from,
- resolve::{resolve_block_range, Outcome, SolvedAmount, SteppingSolver},
+ resolve::{resolve_block_range, Outcome, RangeComparison, SolvedAmount, SteppingSolver},
telemetry,
usd::Prices,
};
@@ -500,9 +500,12 @@ pub(crate) async fn run(cfg: MonitorArgs) -> anyhow::Result<()> {
let mut comparisons = match cfg.comparisons_dir.as_ref() {
Some(dir) => {
- let writer = super::jsonl::RotatingWriter::open(dir)?;
- info!(path = %writer.current_path().display(), "appending comparisons to JSONL");
- Some(writer)
+ let comparisons = super::jsonl::RotatingWriter::open(dir)?;
+ info!(
+ comparisons_path = %comparisons.current_path().display(),
+ "appending comparisons to JSONL"
+ );
+ Some(comparisons)
}
None => None,
};
@@ -617,59 +620,50 @@ async fn run_session(
}
}
- let trades = match decode_block_when_available(decoder, target, pacing.rpc_lag_budget).await
- {
- Ok(trades) => trades,
- Err(e) => {
- totals.skipped_blocks += 1;
- telemetry::record_skipped_block();
- warn!(
- block = target,
- skipped_total = totals.skipped_blocks,
- "decode failed, skipping block: {e}"
- );
- if let Err(e) = adapter.advance().await {
- return SessionEnd::Unhealthy(e.to_string());
+ let decoded =
+ match decode_block_when_available(decoder, target, pacing.rpc_lag_budget).await {
+ Ok(decoded) => decoded,
+ Err(e) => {
+ totals.skipped_blocks += 1;
+ telemetry::record_skipped_block();
+ warn!(
+ block = target,
+ skipped_total = totals.skipped_blocks,
+ "decode failed, skipping block: {e}"
+ );
+ if let Err(e) = adapter.advance().await {
+ return SessionEnd::Unhealthy(e.to_string());
+ }
+ continue;
}
- continue;
- }
- };
+ };
let start = Instant::now();
// 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 {
- Ok(ranges) => ranges,
+ let (ranges, prices_back) = match resolve_and_snapshot_back(
+ adapter,
+ decoder,
+ &decoded,
+ &prices_top,
+ target,
+ )
+ .await
+ {
+ Ok(resolved) => resolved,
Err(e) => return SessionEnd::Unhealthy(e.to_string()),
};
- // resolve_block_range advanced the solver to back-of-block (N); snapshot again so the
- // back-of-block improvement is valued against the state it was solved at.
- let prices_back = snapshot_prices(adapter.solver, decoder.registry()).await;
- // The back-of-block solve should land on `target`. On a reorg/gap/resync the stream can
- // apply a different block, silently pairing the back state with another block's trades.
- // The top-of-block (N-1) headline is unaffected; warn so the mispaired back state is
- // visible.
- let applied = adapter.current_block().await;
- if applied != Some(target) {
- warn!(
- target,
- applied = ?applied,
- "back-of-block state is not the target block; back comparison may be off"
- );
- }
- for range in &ranges {
- telemetry::record_range(
- range,
- &cfg.chain.name,
- &prices_top,
- &prices_back,
- decoder.registry(),
- );
- }
- if let Some(rotating) = comparisons.as_mut() {
- super::jsonl::write_comparisons(rotating.writer(), &ranges, &prices_top, &prices_back);
- }
+ record_block_resolution(
+ &BlockRecording {
+ chain: &cfg.chain.name,
+ registry: decoder.registry(),
+ ranges: &ranges,
+ prices_top: &prices_top,
+ prices_back: &prices_back,
+ },
+ comparisons,
+ );
let elapsed_s = start.elapsed().as_secs_f64();
telemetry::record_block_seconds(elapsed_s);
@@ -686,6 +680,71 @@ async fn run_session(
}
}
+/// Resolve one block's trades (settled and reverted) at both states, then snapshot back-of-block
+/// prices.
+///
+/// `resolve_block_range` advances the solver to back-of-block (N) as a side effect, so the price
+/// snapshot must be taken after it returns to value the back-of-block improvement against the
+/// state it was solved at. The back-of-block solve should land on `target`; on a reorg, gap, or
+/// resync the stream can apply a different block, silently pairing the back state with another
+/// block's trades — the top-of-block (N-1) headline is unaffected, but this is worth a warning so
+/// the mispairing is visible.
+async fn resolve_and_snapshot_back(
+ adapter: &StepAdapter<'_>,
+ decoder: &Decoder,
+ decoded: &[DecodedTrade],
+ prices_top: &Prices,
+ target: u64,
+) -> anyhow::Result<(Vec, Prices)> {
+ let ranges = resolve_block_range(adapter, decoded, prices_top).await?;
+ let prices_back = snapshot_prices(adapter.solver, decoder.registry()).await;
+ let applied = adapter.current_block().await;
+ if applied != Some(target) {
+ warn!(
+ target,
+ applied = ?applied,
+ "back-of-block state is not the target block; back comparison may be off"
+ );
+ }
+ Ok((ranges, prices_back))
+}
+
+/// One block's re-solved trades, bundled so the recording helper below takes a handful of
+/// parameters instead of every constituent piece separately.
+struct BlockRecording<'a> {
+ chain: &'a str,
+ registry: &'a Registry,
+ ranges: &'a [RangeComparison],
+ prices_top: &'a Prices,
+ prices_back: &'a Prices,
+}
+
+/// Record and persist one block's re-solved trades — settled and reverted alike, told apart by
+/// each record's `status`: Prometheus metrics, plus the single JSONL stream when the run was
+/// configured with `--comparisons-dir`.
+fn record_block_resolution(
+ block: &BlockRecording<'_>,
+ comparisons: &mut Option,
+) {
+ for range in block.ranges {
+ telemetry::record_range(
+ range,
+ block.chain,
+ block.prices_top,
+ block.prices_back,
+ block.registry,
+ );
+ }
+ if let Some(rotating) = comparisons.as_mut() {
+ super::jsonl::write_comparisons(
+ rotating.writer(),
+ block.ranges,
+ block.prices_top,
+ block.prices_back,
+ );
+ }
+}
+
/// Snapshot the solver's current token prices as `Prices` (token native-units per wei of
/// the gas token), anchored by `registry`'s USD anchor tokens. Empty until the first
/// derived-data computation completes; tokens with an unconvertible price are skipped.
diff --git a/tools/hindsight/src/telemetry.rs b/tools/hindsight/src/telemetry.rs
index 1822ea3f9..cb41d29c5 100644
--- a/tools/hindsight/src/telemetry.rs
+++ b/tools/hindsight/src/telemetry.rs
@@ -6,12 +6,13 @@
use std::time::Duration;
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
+use alloy::primitives::{Address, U256};
use metrics::{counter, describe_counter, describe_histogram, histogram, Unit};
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use tracing::{error, info, warn};
use crate::{
- decoder::Registry,
+ decoder::{Registry, RevertCause, TradeStatus},
resolve::{render_route, Outcome, RangeComparison, StateResult, Verdict},
usd::Prices,
};
@@ -30,6 +31,9 @@ const RPC_INDEX_WAIT: &str = "hindsight_rpc_index_wait_seconds";
const SKIPPED_BLOCKS: &str = "hindsight_skipped_blocks_total";
const FEED_REBUILDS: &str = "hindsight_feed_rebuilds_total";
const UNTRACED_TRANSACTIONS: &str = "hindsight_untraced_transactions_total";
+const REVERTED_SWAPS_TOTAL: &str = "hindsight_reverted_swaps_total";
+const REVERT_FILLABLE_TOTAL: &str = "hindsight_revert_fillable_total";
+const REVERT_MARGIN_BPS: &str = "hindsight_revert_margin_bps";
/// Absolute USD savings beyond which a comparison is logged with full per-trade context, so large
/// outliers can be traced and classified (a genuinely large trade vs a token-mispricing artifact
@@ -107,9 +111,20 @@ pub(crate) fn outcome_label(verdict: Verdict) -> &'static str {
}
}
+/// Metric label for a reverted swap's cause — a closed, bounded set. `Other`'s free-text detail
+/// stays in the JSONL records, never in a label.
+pub(crate) fn revert_cause_label(cause: &RevertCause) -> &'static str {
+ match cause {
+ RevertCause::SlippageFloor => "slippage_floor",
+ RevertCause::OutOfGas => "out_of_gas",
+ RevertCause::Other(_) => "other",
+ }
+}
+
/// Register metric descriptions with the active recorder.
pub(crate) fn describe() {
describe_trade_metrics();
+ describe_revert_metrics();
}
/// Register descriptions for the per-trade re-solve metrics: savings, slippage, volume, and the
@@ -208,9 +223,29 @@ fn describe_trade_metrics() {
);
}
-/// Record a two-state range: the top-of-block (N-1) and back-of-block (N) outcomes, each tagged
-/// with a `state` label ("top"/"back"). `prices_top`/`prices_back` are the solver's token-price
-/// snapshots at each state, used to value savings in USD; an empty map disables USD for that state.
+/// Register descriptions for the reverted-swap metrics.
+fn describe_revert_metrics() {
+ describe_counter!(
+ REVERTED_SWAPS_TOTAL,
+ "Reverted Relay swaps seen, labeled by venue / solver / chain / cause \
+ (slippage_floor|out_of_gas|other) / decoded (true|false). The decoded=false slice is \
+ parser-coverage: reverts whose solver frame or calldata could not be recovered"
+ );
+ describe_counter!(
+ REVERT_FILLABLE_TOTAL,
+ "Decoded, solved reverts whose quote is judged against the trader's on-chain floor \
+ (min_amount_out), labeled by venue / solver / chain / state (top|back) / fillable \
+ (true|false). The avoidance rate is fillable=true over the total at either state"
+ );
+ describe_histogram!(
+ REVERT_MARGIN_BPS,
+ Unit::Count,
+ "Signed bps of (quote - min_amount_out) / min_amount_out for decoded, solved reverts \
+ (positive = cleared the floor with room to spare), labeled by venue / solver / chain / \
+ state (top|back)"
+ );
+}
+/// Record one re-solved trade — settled or reverted, told apart by `range.status`.
pub(crate) fn record_range(
range: &RangeComparison,
chain: &str,
@@ -218,6 +253,49 @@ pub(crate) fn record_range(
prices_back: &Prices,
registry: &Registry,
) {
+ match &range.status {
+ TradeStatus::Settled => {
+ record_settled_range(range, chain, prices_top, prices_back, registry);
+ }
+ TradeStatus::Reverted { cause } => record_reverted_range(range, cause, chain, registry),
+ }
+}
+
+/// Record a settled trade's two-state range: the top-of-block (N-1) and back-of-block (N)
+/// outcomes, each tagged with a `state` label ("top"/"back"). `prices_top`/`prices_back` are the
+/// solver's token-price snapshots at each state, used to value savings in USD; an empty map
+/// disables USD for that state.
+fn record_settled_range(
+ range: &RangeComparison,
+ chain: &str,
+ prices_top: &Prices,
+ prices_back: &Prices,
+ registry: &Registry,
+) {
+ // A settled trade always carries these by construction (see `DecodedTrade`'s settled/
+ // reverted invariant); the early return is a defensive guard, not the expected path.
+ let (
+ Some(top),
+ Some(back),
+ Some(verdict),
+ Some(token_in),
+ Some(token_out),
+ Some(amount_in),
+ Some(settled_amount_out),
+ ) = (
+ &range.top,
+ &range.back,
+ range.verdict,
+ range.token_in,
+ range.token_out,
+ range.amount_in,
+ range.settled_amount_out,
+ )
+ else {
+ warn!(tx = %range.tx_hash, "settled range is missing terms or solved states; skipping metrics");
+ return;
+ };
+
let labels = MetricLabels {
venue: venue_label(&range.venue, registry),
solver: solver_label(&range.solver, registry),
@@ -231,15 +309,15 @@ pub(crate) fn record_range(
// outside the solver's graph) — without the fallback, unsolvable volume would be
// systematically undercounted.
let volume = prices_top
- .value_usd(range.token_out, range.settled_amount_out)
- .or_else(|| prices_top.value_usd(range.token_in, range.amount_in));
+ .value_usd(token_out, settled_amount_out)
+ .or_else(|| prices_top.value_usd(token_in, amount_in));
if let Some(volume) = volume {
histogram!(
VOLUME_USD,
"venue" => labels.venue.to_string(),
"solver" => labels.solver.to_string(),
"chain" => labels.chain.to_string(),
- "outcome" => outcome_label(range.verdict).to_string(),
+ "outcome" => outcome_label(verdict).to_string(),
)
.record(volume);
}
@@ -249,9 +327,10 @@ pub(crate) fn record_range(
// notional and is kept. The notional is a per-trade quantity, so both states share this gate.
let above_floor = volume.is_none_or(|usd| usd >= MIN_NOTIONAL_USD);
- let savings_top = record_state(range, &range.top, "top", &labels, prices_top, above_floor);
- record_state(range, &range.back, "back", &labels, prices_back, above_floor);
- record_slippage(range, &labels, prices_back);
+ let settled = (token_out, settled_amount_out);
+ let savings_top = record_state(range, top, "top", &labels, prices_top, above_floor, settled);
+ record_state(range, back, "back", &labels, prices_back, above_floor, settled);
+ record_slippage(range, &labels, prices_back, token_out, verdict);
// One structured line per priced comparison, on the headline basis (top-of-block, gross).
// Loki ingests pod stdout, so this line feeds the dashboard's top-trades table; keep the
@@ -262,10 +341,10 @@ pub(crate) fn record_range(
// `route` is last on purpose: it is the only field whose value contains spaces, so a LogQL
// regexp can only bound it by end-of-line. Keep it last, or the dashboard's route column
// silently swallows every field after it.
- if let (Some(savings_usd), Outcome::Solved(solved)) = (savings_top, &range.top.outcome) {
+ if let (Some(savings_usd), Outcome::Solved(solved)) = (savings_top, &top.outcome) {
let priced = |amount| {
prices_top
- .value_usd(range.token_out, amount)
+ .value_usd(token_out, amount)
.unwrap_or(0.0)
};
info!(
@@ -273,12 +352,12 @@ pub(crate) fn record_range(
block = range.block_number,
venue = %range.venue,
solver = %range.solver,
- token_in = %range.token_in,
- token_out = %range.token_out,
- verdict = %outcome_label(range.verdict),
- algorithm = %algorithm_label(&range.top.outcome),
+ token_in = %token_in,
+ token_out = %token_out,
+ verdict = %outcome_label(verdict),
+ algorithm = %algorithm_label(&top.outcome),
volume_usd = volume.unwrap_or(0.0),
- settled_usd = priced(range.settled_amount_out),
+ settled_usd = priced(settled_amount_out),
fynd_usd = priced(solved.amount_out),
quoted_usd = range.declared_quote.map_or(0.0, priced),
savings_usd,
@@ -288,16 +367,16 @@ pub(crate) fn record_range(
}
}
-/// Record one block-state of a range under a `state` label. Emits the trade counter, and — for a
-/// solved state — the gross bps delta, the signed USD savings, and the USD uplift (only when
-/// Fynd beats the settled trade; a venue routes elsewhere when Fynd is worse). All highlighted
-/// metrics compare gross vs gross, matching the headline verdict.
+/// Record one block-state of a settled range under a `state` label. Emits the trade counter, and
+/// — for a solved state — the gross bps delta, the signed USD savings, and the USD uplift (only
+/// when Fynd beats the settled trade; a venue routes elsewhere when Fynd is worse). All
+/// highlighted metrics compare gross vs gross, matching the headline verdict.
///
/// A sandwiched state's output was moved by MEV, not by Fynd's own routing, so it skips the
/// `SAVINGS_BPS`/`SAVINGS_USD`/`IMPROVEMENT_USD` histograms — the USD histograms carry no
/// outcome label, so skipping is the only way to keep the "value of adding Fynd" aggregates
/// clean. The USD value is still computed and returned so the per-trade Loki line (in
-/// `record_range`) keeps logging.
+/// `record_settled_range`) keeps logging.
///
/// Returns the signed USD savings it computed, `None` when the state is unsolved or unpriced.
fn record_state(
@@ -307,7 +386,9 @@ fn record_state(
labels: &MetricLabels<'_>,
prices: &Prices,
above_floor: bool,
+ settled: (Address, U256),
) -> Option {
+ let (token_out, settled_amount_out) = settled;
let algorithm = algorithm_label(&state.outcome);
if above_floor {
@@ -343,7 +424,7 @@ fn record_state(
let Outcome::Solved(solved) = &state.outcome else {
return None;
};
- let usd = prices.savings_usd(range.token_out, solved.amount_out, range.settled_amount_out)?;
+ let usd = prices.savings_usd(token_out, solved.amount_out, settled_amount_out)?;
if sandwiched {
return Some(usd);
}
@@ -355,12 +436,9 @@ fn record_state(
state = state_label,
venue = %range.venue,
solver = %range.solver,
- token_in = %range.token_in,
- token_out = %range.token_out,
- amount_in = %range.amount_in,
- settled_out = %range.settled_amount_out,
+ settled_out = %settled_amount_out,
fynd_out = %solved.amount_out,
- token_out_price = ?prices.get(range.token_out),
+ token_out_price = ?prices.get(token_out),
usd,
"USD outlier — inspect for token mispricing vs genuinely large trade"
);
@@ -396,11 +474,17 @@ fn record_state(
/// Valued at `prices_back`, the state the surplus is realized at. Sandwiched trades are not
/// skipped: the comparison is Fynd-quote vs Fynd-re-execution, so the settled trade's MEV does
/// not enter it, and block N's pool moves are real either way.
-fn record_slippage(range: &RangeComparison, labels: &MetricLabels<'_>, prices: &Prices) {
+fn record_slippage(
+ range: &RangeComparison,
+ labels: &MetricLabels<'_>,
+ prices: &Prices,
+ token_out: Address,
+ verdict: Verdict,
+) {
let Some(slippage) = range.slippage else {
return;
};
- let outcome = outcome_label(range.verdict).to_string();
+ let outcome = outcome_label(verdict).to_string();
histogram!(
SLIPPAGE_BPS,
"venue" => labels.venue.to_string(),
@@ -410,11 +494,9 @@ fn record_slippage(range: &RangeComparison, labels: &MetricLabels<'_>, prices: &
)
.record(slippage.bps);
- let Some(usd) = prices.savings_usd(
- range.token_out,
- slippage.reexecuted_amount_out,
- slippage.quoted_amount_out,
- ) else {
+ let Some(usd) =
+ prices.savings_usd(token_out, slippage.reexecuted_amount_out, slippage.quoted_amount_out)
+ else {
return;
};
histogram!(
@@ -435,10 +517,10 @@ fn record_slippage(range: &RangeComparison, labels: &MetricLabels<'_>, prices: &
block = range.block_number,
venue = %range.venue,
solver = %range.solver,
- token_out = %range.token_out,
+ token_out = %token_out,
quoted_out = %slippage.quoted_amount_out,
reexecuted_out = %slippage.reexecuted_amount_out,
- token_out_price = ?prices.get(range.token_out),
+ token_out_price = ?prices.get(token_out),
usd,
"positive slippage USD outlier — inspect for token mispricing vs a genuine pool move"
);
@@ -452,6 +534,64 @@ fn record_slippage(range: &RangeComparison, labels: &MetricLabels<'_>, prices: &
.record(usd);
}
+/// Record a reverted trade: always the "seen" counter (parser coverage, via the `decoded` label —
+/// whether its solver calldata parsed into terms), and — when its terms were known enough to
+/// solve — each state's fillable/margin judgment against `min_amount_out`.
+fn record_reverted_range(
+ range: &RangeComparison,
+ cause: &RevertCause,
+ chain: &str,
+ registry: &Registry,
+) {
+ counter!(
+ REVERTED_SWAPS_TOTAL,
+ "venue" => venue_label(&range.venue, registry).to_string(),
+ "solver" => solver_label(&range.solver, registry).to_string(),
+ "chain" => chain.to_string(),
+ "cause" => revert_cause_label(cause).to_string(),
+ "decoded" => if range.token_in.is_some() { "true" } else { "false" },
+ )
+ .increment(1);
+
+ let labels = MetricLabels {
+ venue: venue_label(&range.venue, registry),
+ solver: solver_label(&range.solver, registry),
+ chain,
+ };
+ if let Some(top) = &range.top {
+ record_revert_state(top, "top", &labels);
+ }
+ if let Some(back) = &range.back {
+ record_revert_state(back, "back", &labels);
+ }
+}
+
+/// Record one state of a revert judgment: the fillable counter and the signed margin histogram,
+/// both only when the state was solved (there is nothing to judge otherwise).
+fn record_revert_state(state: &StateResult, state_label: &'static str, labels: &MetricLabels<'_>) {
+ if let Some(fillable) = state.fillable {
+ counter!(
+ REVERT_FILLABLE_TOTAL,
+ "venue" => labels.venue.to_string(),
+ "solver" => labels.solver.to_string(),
+ "chain" => labels.chain.to_string(),
+ "state" => state_label,
+ "fillable" => if fillable { "true" } else { "false" },
+ )
+ .increment(1);
+ }
+ if let Some(margin_bps) = state.margin_bps {
+ histogram!(
+ REVERT_MARGIN_BPS,
+ "venue" => labels.venue.to_string(),
+ "solver" => labels.solver.to_string(),
+ "chain" => labels.chain.to_string(),
+ "state" => state_label,
+ )
+ .record(margin_bps);
+ }
+}
+
pub(crate) fn record_block_seconds(seconds: f64) {
histogram!(BLOCK_SECONDS).record(seconds);
}
@@ -557,6 +697,7 @@ fn configure_buckets(
.set_buckets_for_metric(Matcher::Full(IMPROVEMENT_USD.into()), SAVINGS_USD_BUCKETS)?
.set_buckets_for_metric(Matcher::Full(SLIPPAGE_BPS.into()), SAVINGS_BPS_BUCKETS)?
.set_buckets_for_metric(Matcher::Full(SLIPPAGE_USD.into()), SAVINGS_USD_BUCKETS)?
+ .set_buckets_for_metric(Matcher::Full(REVERT_MARGIN_BPS.into()), SAVINGS_BPS_BUCKETS)?
.set_buckets_for_metric(
Matcher::Full(POSITIVE_SLIPPAGE_USD.into()),
POSITIVE_SLIPPAGE_USD_BUCKETS,
@@ -571,6 +712,7 @@ fn configure_buckets(
mod tests {
use alloy::primitives::{address, Address, TxHash, U256};
use metrics_exporter_prometheus::PrometheusBuilder;
+ use tycho_simulation::tycho_common::models::Chain;
use super::*;
use crate::{
@@ -587,15 +729,68 @@ mod tests {
tx_hash: TxHash::default(),
block_number: 21_000_000,
tx_index: 0,
+ status: TradeStatus::Settled,
venue: "relay".into(),
solver: "tycho".into(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
sender: Address::ZERO,
- token_in: Address::repeat_byte(0x11),
- token_out,
- amount_in: U256::from(1_000u64),
- amount_out: U256::from(settled),
+ token_in: Some(Address::repeat_byte(0x11)),
+ token_out: Some(token_out),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: Some(U256::from(settled)),
+ venue_fee_in: None,
+ venue_fee_out: None,
+ settled_gas: None,
+ min_amount_out: None,
+ declared_quote: None,
+ quote_timestamp: None,
+ sandwich: None,
+ }
+ }
+
+ /// A reverted trade with known terms and a floor, for the unified revert-metrics tests.
+ fn reverted_trade(cause: RevertCause, min_amount_out: u64) -> DecodedTrade {
+ DecodedTrade {
+ tx_hash: TxHash::default(),
+ block_number: 21_000_000,
+ tx_index: 0,
+ status: TradeStatus::Reverted { cause },
+ venue: "relay".into(),
+ solver: "fly".into(),
+ solver_source: AttributionSource::TraceMatch,
+ decoder: "reverted",
+ sender: Address::ZERO,
+ token_in: Some(Address::repeat_byte(0x11)),
+ token_out: Some(Address::repeat_byte(0x22)),
+ amount_in: Some(U256::from(1_000u64)),
+ amount_out: None,
+ venue_fee_in: None,
+ venue_fee_out: None,
+ settled_gas: None,
+ min_amount_out: Some(U256::from(min_amount_out)),
+ declared_quote: None,
+ quote_timestamp: None,
+ sandwich: None,
+ }
+ }
+
+ /// A reverted trade whose solver calldata did not parse — no terms, nothing to solve.
+ fn undecoded_reverted_trade(cause: RevertCause) -> DecodedTrade {
+ DecodedTrade {
+ tx_hash: TxHash::default(),
+ block_number: 21_000_000,
+ tx_index: 1,
+ status: TradeStatus::Reverted { cause },
+ venue: "relay".into(),
+ solver: "relay".into(),
+ solver_source: AttributionSource::Fallback,
+ decoder: "reverted",
+ sender: Address::ZERO,
+ token_in: None,
+ token_out: None,
+ amount_in: None,
+ amount_out: None,
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
@@ -649,9 +844,11 @@ mod tests {
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()),
+ Some((
+ 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()),
+ )),
);
let mut prices = empty_prices();
prices.insert(usdc, 2e-9);
@@ -690,9 +887,11 @@ mod tests {
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()),
+ Some((
+ 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()),
+ )),
);
let recorder = PrometheusBuilder::new().build_recorder();
let handle = recorder.handle();
@@ -747,9 +946,11 @@ mod tests {
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),
+ Some((
+ solved(1_010_000_000, 1_005_000_000),
+ solved(998_000_000, 995_000_000),
+ solved(998_000_000, 995_000_000),
+ )),
);
// USDC priced at 2e-9 native-units per ETH-wei (ETH = $2000) anchors ETH→USD.
let mut prices = empty_prices();
@@ -802,9 +1003,11 @@ mod tests {
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),
+ Some((
+ 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),
+ )),
);
let mut prices = empty_prices();
prices.insert(usdc, 2e-9);
@@ -845,9 +1048,11 @@ mod tests {
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),
+ Some((
+ solved(1_000_000_000, 995_000_000),
+ solved(995_000_000, 990_000_000),
+ solved(995_000_000, 990_000_000),
+ )),
);
let mut prices = empty_prices();
prices.insert(usdc, 2e-9);
@@ -886,9 +1091,11 @@ mod tests {
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()),
+ Some((
+ 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()),
+ )),
);
let mut prices = empty_prices();
prices.insert(usdc, 2e-9);
@@ -937,9 +1144,7 @@ mod tests {
let range = build_range(
&t,
&empty_prices(),
- solved(1_100, 1_050),
- solved(1_100, 1_050),
- &solved(1_100, 1_050),
+ Some((solved(1_100, 1_050), solved(1_100, 1_050), solved(1_100, 1_050))),
);
let recorder = PrometheusBuilder::new().build_recorder();
@@ -970,9 +1175,7 @@ mod tests {
let range = build_range(
&t,
&empty_prices(),
- solved(1_100, 1_050),
- solved(1_100, 1_050),
- &solved(1_100, 1_050),
+ Some((solved(1_100, 1_050), solved(1_100, 1_050), solved(1_100, 1_050))),
);
let recorder = PrometheusBuilder::new().build_recorder();
@@ -998,14 +1201,16 @@ mod tests {
// unsolvable volume would be systematically undercounted.
let usdc = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48");
let mut t = trade(Address::repeat_byte(0x42), 1_000);
- t.token_in = usdc;
- t.amount_in = U256::from(1_000_000_000u64); // 1000 USDC
+ t.token_in = Some(usdc);
+ t.amount_in = Some(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()),
+ Some((
+ Outcome::Unsolvable("no route".into()),
+ Outcome::Unsolvable("no route".into()),
+ Outcome::Unsolvable("no top-of-block route to re-execute".into()),
+ )),
);
let mut prices = empty_prices();
prices.insert(usdc, 2e-9);
@@ -1030,9 +1235,11 @@ mod tests {
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()),
+ Some((
+ Outcome::Unsolvable("x".into()),
+ Outcome::Unsolvable("x".into()),
+ Outcome::Unsolvable("x".into()),
+ )),
);
let recorder = PrometheusBuilder::new().build_recorder();
let handle = recorder.handle();
@@ -1067,11 +1274,13 @@ mod tests {
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),
+ Some((
+ 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),
+ )),
);
- assert_eq!(range.verdict, Verdict::Sandwiched);
+ assert_eq!(range.verdict, Some(Verdict::Sandwiched));
let recorder = configure_buckets(PrometheusBuilder::new())
.unwrap()
@@ -1099,9 +1308,11 @@ mod tests {
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),
+ Some((
+ solved(1_005_000, 1_005_000),
+ solved(1_005_000, 1_005_000),
+ solved(1_005_000, 1_005_000),
+ )),
);
let mut prices = empty_prices();
prices.insert(usdc, 2e-9);
@@ -1130,9 +1341,7 @@ mod tests {
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),
+ Some((solved(1_100, 1_050), solved(1_100, 1_050), solved(1_100, 1_050))),
);
let recorder = configure_buckets(PrometheusBuilder::new())
.unwrap()
@@ -1151,4 +1360,133 @@ mod tests {
assert!(rendered.contains("hindsight_trades_total"), "rendered: {rendered}");
assert!(rendered.contains("hindsight_savings_bps"), "rendered: {rendered}");
}
+
+ #[test]
+ fn test_revert_cause_labels() {
+ assert_eq!(revert_cause_label(&RevertCause::SlippageFloor), "slippage_floor");
+ assert_eq!(revert_cause_label(&RevertCause::OutOfGas), "out_of_gas");
+ assert_eq!(
+ revert_cause_label(&RevertCause::Other("execution reverted".to_string())),
+ "other"
+ );
+ }
+
+ #[test]
+ fn test_record_range_reverted_labels_decoded_and_cause() {
+ let decoded =
+ build_range(&reverted_trade(RevertCause::SlippageFloor, 10_000), &empty_prices(), None);
+ let undecoded =
+ build_range(&undecoded_reverted_trade(RevertCause::OutOfGas), &empty_prices(), None);
+
+ let recorder = PrometheusBuilder::new().build_recorder();
+ let handle = recorder.handle();
+ metrics::with_local_recorder(&recorder, || {
+ record_range(
+ &decoded,
+ "base",
+ &empty_prices(),
+ &empty_prices(),
+ &Registry::builtin(Chain::Base).unwrap(),
+ );
+ record_range(
+ &undecoded,
+ "base",
+ &empty_prices(),
+ &empty_prices(),
+ &Registry::builtin(Chain::Base).unwrap(),
+ );
+ });
+ let rendered = handle.render();
+
+ assert!(rendered.contains("cause=\"slippage_floor\""), "rendered: {rendered}");
+ assert!(rendered.contains("cause=\"out_of_gas\""), "rendered: {rendered}");
+ assert!(rendered.contains("decoded=\"true\""), "rendered: {rendered}");
+ assert!(rendered.contains("decoded=\"false\""), "rendered: {rendered}");
+ }
+
+ #[test]
+ fn test_record_range_reverted_fillable_and_margin() {
+ let trade = reverted_trade(RevertCause::SlippageFloor, 10_000);
+ let range = build_range(
+ &trade,
+ &empty_prices(),
+ Some((solved(9_800, 9_700), solved(10_200, 10_100), solved(10_200, 10_100))),
+ );
+
+ let recorder = configure_buckets(PrometheusBuilder::new())
+ .unwrap()
+ .build_recorder();
+ let handle = recorder.handle();
+ metrics::with_local_recorder(&recorder, || {
+ record_range(
+ &range,
+ "base",
+ &empty_prices(),
+ &empty_prices(),
+ &Registry::builtin(Chain::Base).unwrap(),
+ );
+ });
+ let rendered = handle.render();
+
+ assert!(rendered.contains("hindsight_revert_fillable_total"), "rendered: {rendered}");
+ assert!(rendered.contains("state=\"top\""), "rendered: {rendered}");
+ assert!(rendered.contains("state=\"back\""), "rendered: {rendered}");
+ assert!(rendered.contains("fillable=\"false\""), "rendered: {rendered}");
+ assert!(rendered.contains("fillable=\"true\""), "rendered: {rendered}");
+ assert!(rendered.contains("hindsight_revert_margin_bps_bucket"), "rendered: {rendered}");
+ }
+
+ #[test]
+ fn test_record_range_reverted_unsolved_state_skips_fillable_metrics() {
+ let trade = reverted_trade(RevertCause::OutOfGas, 10_000);
+ let range = build_range(
+ &trade,
+ &empty_prices(),
+ Some((
+ Outcome::Unsolvable("no route".into()),
+ Outcome::Unsolvable("no route".into()),
+ Outcome::Unsolvable("no route".into()),
+ )),
+ );
+
+ let recorder = PrometheusBuilder::new().build_recorder();
+ let handle = recorder.handle();
+ metrics::with_local_recorder(&recorder, || {
+ record_range(
+ &range,
+ "base",
+ &empty_prices(),
+ &empty_prices(),
+ &Registry::builtin(Chain::Base).unwrap(),
+ );
+ });
+ let rendered = handle.render();
+
+ assert!(!rendered.contains("hindsight_revert_fillable_total"), "rendered: {rendered}");
+ assert!(!rendered.contains("hindsight_revert_margin_bps"), "rendered: {rendered}");
+ }
+
+ #[test]
+ fn test_record_range_reverted_with_unknown_terms_still_counted_as_seen() {
+ // No terms to solve, but the "seen" counter (parser coverage) must still fire.
+ let trade = undecoded_reverted_trade(RevertCause::Other("execution reverted".to_string()));
+ let range = build_range(&trade, &empty_prices(), None);
+
+ let recorder = PrometheusBuilder::new().build_recorder();
+ let handle = recorder.handle();
+ metrics::with_local_recorder(&recorder, || {
+ record_range(
+ &range,
+ "base",
+ &empty_prices(),
+ &empty_prices(),
+ &Registry::builtin(Chain::Base).unwrap(),
+ );
+ });
+ let rendered = handle.render();
+
+ assert!(rendered.contains("hindsight_reverted_swaps_total"), "rendered: {rendered}");
+ assert!(rendered.contains("decoded=\"false\""), "rendered: {rendered}");
+ assert!(!rendered.contains("hindsight_revert_fillable_total"), "rendered: {rendered}");
+ }
}
diff --git a/tools/hindsight/src/verify/mod.rs b/tools/hindsight/src/verify/mod.rs
index f220eb9bd..91b93328f 100644
--- a/tools/hindsight/src/verify/mod.rs
+++ b/tools/hindsight/src/verify/mod.rs
@@ -17,7 +17,7 @@ use anyhow::Context;
use tracing::warn;
use crate::{
- decoder::{DecodedTrade, Decoder},
+ decoder::{DecodedTrade, Decoder, TradeStatus},
verify::allium::{AlliumClient, AlliumRow},
};
@@ -109,7 +109,12 @@ pub(crate) async fn run(
let mut decimals = HashMap::new();
for &block in blocks {
let ours = match decoder.decode_block(block).await {
- Ok(ours) => ours,
+ // Allium's `aggregator_trades` only records swaps that settled; a reverted trade has
+ // no counterpart there to diff against.
+ Ok(decoded) => decoded
+ .into_iter()
+ .filter(|trade| trade.status == TradeStatus::Settled)
+ .collect::>(),
Err(error) => {
warn!(block, %error, "failed to decode block; skipping");
continue;
@@ -188,6 +193,27 @@ async fn compare_block(
}
}
+/// The settled swap terms a comparison needs, extracted from a `DecodedTrade` up front so the
+/// rest of this module can work with plain values rather than re-checking `Option`s everywhere.
+/// `run` only ever passes settled trades into `compare_block` (Allium has nothing to diff a
+/// revert against), so by the settled/reverted invariant these are always present — but the type
+/// system does not know that from a filter, so a missing term is still handled, not unwrapped.
+struct SettledTerms {
+ token_in: Address,
+ token_out: Address,
+ amount_in: U256,
+ amount_out: U256,
+}
+
+fn settled_terms(trade: &DecodedTrade) -> Option {
+ Some(SettledTerms {
+ token_in: trade.token_in?,
+ token_out: trade.token_out?,
+ amount_in: trade.amount_in?,
+ amount_out: trade.amount_out?,
+ })
+}
+
async fn compare_trade(
provider: &P,
ours: &DecodedTrade,
@@ -195,10 +221,20 @@ async fn compare_trade(
tolerance_bps: f64,
decimals: &mut HashMap,
) -> TxComparison {
+ let Some(terms) = settled_terms(ours) else {
+ return TxComparison {
+ tx_hash: ours.tx_hash,
+ status: Status::OursOnly,
+ detail: "settled trade has no known terms (should have been filtered out earlier)"
+ .to_string(),
+ };
+ };
+
let mut detail = Vec::new();
- let token_ok = tokens_agree(ours, rows, &mut detail);
+ let token_ok = tokens_agree(&terms, rows, &mut detail);
let solver_ok = solver_agrees(ours, rows, &mut detail);
- let amount_ok = amounts_agree(provider, ours, rows, tolerance_bps, decimals, &mut detail).await;
+ let amount_ok =
+ amounts_agree(provider, &terms, rows, tolerance_bps, decimals, &mut detail).await;
let status = if !token_ok {
Status::TokenMismatch
@@ -215,8 +251,8 @@ async fn compare_trade(
/// Allium splits a multi-leg swap into per-leg rows, so check membership in sets rather than
/// exact one-to-one matching: our netted `token_in` must appear among the sold tokens and our
-/// `token_out` among the bought tokens across all rows for this tx.
-fn tokens_agree(ours: &DecodedTrade, rows: &[&AlliumRow], detail: &mut Vec) -> bool {
+/// `token_out` among the bought tokens across all rows for a tx.
+fn tokens_agree(terms: &SettledTerms, rows: &[&AlliumRow], detail: &mut Vec) -> bool {
let sold: HashSet = rows
.iter()
.filter_map(|r| {
@@ -234,13 +270,13 @@ fn tokens_agree(ours: &DecodedTrade, rows: &[&AlliumRow], detail: &mut Vec(
provider: &P,
- ours: &DecodedTrade,
+ terms: &SettledTerms,
rows: &[&AlliumRow],
tolerance_bps: f64,
decimals: &mut HashMap,
@@ -281,11 +317,15 @@ async fn amounts_agree(
return true;
};
- let in_leg =
- AmountLeg { token: ours.token_in, ours: ours.amount_in, theirs: sold, field: "amount_in" };
+ let in_leg = AmountLeg {
+ token: terms.token_in,
+ ours: terms.amount_in,
+ theirs: sold,
+ field: "amount_in",
+ };
let out_leg = AmountLeg {
- token: ours.token_out,
- ours: ours.amount_out,
+ token: terms.token_out,
+ ours: terms.amount_out,
theirs: bought,
field: "amount_out",
};
@@ -418,15 +458,16 @@ mod tests {
tx_hash: TxHash::ZERO,
block_number: 1,
tx_index: 0,
+ status: crate::decoder::TradeStatus::Settled,
venue: "relay".to_string(),
solver: solver.to_string(),
solver_source: AttributionSource::TraceMatch,
decoder: "sender-netting",
sender: addr(1),
- token_in,
- token_out,
- amount_in: U256::from(1000),
- amount_out: U256::from(2000),
+ token_in: Some(token_in),
+ token_out: Some(token_out),
+ amount_in: Some(U256::from(1000)),
+ amount_out: Some(U256::from(2000)),
venue_fee_in: None,
venue_fee_out: None,
settled_gas: None,
@@ -451,21 +492,33 @@ mod tests {
#[test]
fn test_token_match_both_present() {
let ours = trade(addr(10), addr(11), "1inch");
+ let terms = settled_terms(&ours).unwrap();
let allium = row(addr(10), addr(11), "1inch");
let mut detail = Vec::new();
- assert!(tokens_agree(&ours, &[&allium], &mut detail));
+ assert!(tokens_agree(&terms, &[&allium], &mut detail));
assert!(detail.is_empty());
}
#[test]
fn test_token_match_mismatched_tokens() {
let ours = trade(addr(10), addr(99), "1inch");
+ let terms = settled_terms(&ours).unwrap();
let allium = row(addr(10), addr(11), "1inch");
let mut detail = Vec::new();
- assert!(!tokens_agree(&ours, &[&allium], &mut detail));
+ assert!(!tokens_agree(&terms, &[&allium], &mut detail));
assert_eq!(detail.len(), 1);
}
+ #[test]
+ fn test_settled_terms_is_none_for_a_reverted_trade() {
+ let mut ours = trade(addr(10), addr(11), "1inch");
+ ours.status = crate::decoder::TradeStatus::Reverted {
+ cause: crate::decoder::RevertCause::Other("execution reverted".to_string()),
+ };
+ ours.token_in = None;
+ assert!(settled_terms(&ours).is_none());
+ }
+
#[test]
fn test_solver_match_prefix() {
let ours = trade(addr(10), addr(11), "uniswap");