diff --git a/Cargo.lock b/Cargo.lock index 77ed0451..5fd8e5be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3835,6 +3835,7 @@ dependencies = [ "alloy", "anyhow", "async-trait", + "chrono", "clap", "futures 0.3.32", "fynd-client", @@ -3852,6 +3853,7 @@ dependencies = [ "tracing", "tracing-subscriber 0.3.23", "tycho-simulation", + "typetag", ] [[package]] diff --git a/fynd-core/src/solver.rs b/fynd-core/src/solver.rs index 623cdc7a..8542a58c 100644 --- a/fynd-core/src/solver.rs +++ b/fynd-core/src/solver.rs @@ -1167,6 +1167,16 @@ impl Solver { self.market_event_tx.subscribe() } + /// Returns a clone of the [`MarketEvent`] broadcast sender. + /// + /// A harness that writes synthetic components into [`MarketData`] must also announce them, or + /// workers never add the edge to their local graph. This is the same channel the Tycho feed + /// publishes real block updates on, so an injected event is indistinguishable from a real one. + #[cfg(feature = "experimental")] + pub fn market_event_sender(&self) -> broadcast::Sender { + self.market_event_tx.clone() + } + /// Submits a [`QuoteRequest`] to the worker pools and returns the best [`Quote`]. /// /// # Errors diff --git a/fynd-core/src/worker_pool_router/mod.rs b/fynd-core/src/worker_pool_router/mod.rs index 1250215b..ee5953bb 100644 --- a/fynd-core/src/worker_pool_router/mod.rs +++ b/fynd-core/src/worker_pool_router/mod.rs @@ -47,6 +47,14 @@ use crate::{ SolveParams, SurplusInfo, }; +/// Minimum improvement over the public route, in basis points of the public route's output net of +/// gas, that an exclusive-access route must deliver before it is quoted. +/// +/// The margin goes to the user: the commitment is raised by it, so the protocol only captures what +/// the route produces on top. A candidate that beats the public route by exactly this margin is +/// still quoted, with zero surplus. +const EXCLUSIVE_ROUTE_IMPROVEMENT_BPS: u64 = 1; + /// Which liquidity a solver pool (a group of workers) routes through, and therefore what its /// candidates mean in a quote. /// @@ -637,22 +645,27 @@ impl WorkerPoolRouter { /// holds the candidates from `ExclusiveAccess`-scoped pools (routes that may use exclusive /// components). /// +/// The user's net target is `required_net = public_net + margin`, where the margin is +/// `EXCLUSIVE_ROUTE_IMPROVEMENT_BPS` of `public_net`. A candidate whose net output falls below +/// `required_net` is skipped, so quoting an exclusive route always leaves the user better off than +/// the public market by at least that margin. +/// /// The committed amount is the larger of two lower bounds: -/// `max(public_amount_out, public_net + exclusive_gas)`. The first guarantees the quoted +/// `max(public_amount_out, required_net + exclusive_gas)`. The first guarantees the quoted /// `amount_out` is never below the public market's; the second guarantees the user — who pays -/// the exclusive route's gas — nets at least what the public route would leave them. +/// the exclusive route's gas — nets at least `required_net`. /// /// Which bound is larger depends on the gas comparison: -/// - `exclusive_gas > public_gas`: committed = `public_net + exclusive_gas`, which exceeds +/// - `exclusive_gas > public_gas`: committed = `required_net + exclusive_gas`, which exceeds /// `public_amount_out`. The user receives more tokens than the public quote and nets exactly -/// `public_net`. +/// `required_net`. /// - `exclusive_gas <= public_gas`: committed = `public_amount_out`. The user nets /// `public_amount_out − exclusive_gas`, which exceeds `public_net` by the gas difference. A -/// commitment of `public_net + exclusive_gas` would still leave the user whole and capture more, -/// but it is below `public_amount_out` — quoting less than the public market is ruled out, so the -/// gas difference stays with the user. +/// commitment of `required_net + exclusive_gas` would still leave the user whole and capture +/// more, but it is below `public_amount_out` — quoting less than the public market is ruled out, +/// so the gas difference stays with the user. /// -/// If the best exclusive-access candidate beats the public reference net-of-gas and produces at +/// If the best exclusive-access candidate clears `required_net` and produces at /// least the committed amount, this returns a new list whose head is the pinned surplus quote, /// followed by every public candidate as price-guard fallbacks. The pinned quote is the winning /// candidate with: @@ -661,7 +674,8 @@ impl WorkerPoolRouter { /// - an order-level [`SurplusInfo`] attached (observability). /// /// Otherwise `public_ranked` is returned unchanged. Either way the user is never worse off than -/// the public market — neither in quoted `amount_out` nor net of gas. +/// the public market — neither in quoted `amount_out` nor net of gas. A candidate that clears +/// `required_net` exactly is quoted with zero surplus: the whole improvement goes to the user. /// /// Per-leg attribution: the route's excess over the committed amount (`realized − committed`) /// is deducted from the exclusive legs — each leg absorbs what it can, capped at its own output, @@ -712,8 +726,9 @@ fn combine_with_surplus( return public_ranked; }; - // The candidate route must beat the committed reference net-of-gas. - if exclusive_candidate.amount_out_net_gas() <= committed.amount_out_net_gas() { + // The candidate route must beat the public reference net-of-gas by the improvement margin. + let required_net_amount_out = with_improvement_margin(committed.amount_out_net_gas()); + if exclusive_candidate.amount_out_net_gas() < &required_net_amount_out { return public_ranked; } @@ -734,10 +749,11 @@ fn combine_with_surplus( // The commitment is the larger of two lower bounds: the quoted amount_out is never below // the public market's, and the user — who pays the exclusive route's gas — never nets less - // than the public route would leave them (public_net + gas). Together with the strict net - // check above, these gates guarantee the route covers the committed amount. + // than the public route would leave them plus the improvement margin (required_net + gas). + // Together with the net check above, these gates guarantee the route covers the committed + // amount. let committed_amount_out = - (committed.amount_out_net_gas() + &exclusive_gas_cost).max(public_amount_out.clone()); + (&required_net_amount_out + &exclusive_gas_cost).max(public_amount_out.clone()); // What the hooks capture: everything the route produces above the commitment. let surplus_amount = exclusive_route_amount_out - &committed_amount_out; @@ -818,6 +834,17 @@ fn combine_with_surplus( result } +/// Returns `amount` raised by `EXCLUSIVE_ROUTE_IMPROVEMENT_BPS`. +/// +/// The margin is rounded up and is at least one atomic unit, so it never rounds away on small +/// amounts: an exclusive route that clears it always leaves the user strictly better off. +fn with_improvement_margin(amount: &BigUint) -> BigUint { + let denominator = BigUint::from(10_000u64); + let margin = (amount * BigUint::from(EXCLUSIVE_ROUTE_IMPROVEMENT_BPS) + (&denominator - 1u64)) / + &denominator; + amount + margin.max(BigUint::from(1u64)) +} + /// Returns `true` only for routes carrying exactly one exclusive leg, positioned as the terminal /// leg of its path. /// @@ -1835,19 +1862,31 @@ mod tests { /// Head selection across the gate case matrix. Each route is given as `(gross, net)`; /// expected is `(head amount_out, captured surplus)`. A surplus win prepends the pinned /// quote to the public fallbacks; otherwise the public ranking is returned unchanged. + /// + /// The improvement margin on these amounts is 1 (the one-atomic-unit floor) except in the + /// 1_000_000 cases, where 1 bps is 100. #[rstest] - #[case::exclusive_beats_public((900, 900), (950, 950), (900, 900, Some(50)))] + #[case::exclusive_beats_public((900, 900), (950, 950), (901, 901, Some(49)))] #[case::exclusive_below_public((950, 950), (900, 900), (950, 950, None))] #[case::exclusive_ties_public_net((900, 900), (900, 900), (900, 900, None))] - #[case::exclusive_marginally_better((999, 999), (1000, 1000), (999, 999, Some(1)))] + #[case::exclusive_marginally_better((999, 999), (1000, 1000), (1000, 1000, Some(0)))] #[case::exclusive_cannot_cover_public_gross((1000, 950), (990, 980), (1000, 950, None))] - // Gas-heavier exclusive route: the committed amount rises to max(1000, 950 + 140) = 1090 so - // the user still nets the public 950; the protocol captures 1100 - 1090 = 10. - #[case::exclusive_with_higher_gas((1000, 950), (1100, 960), (1090, 950, Some(10)))] + // Gas-heavier exclusive route: the committed amount rises to max(1000, 951 + 140) = 1091 so + // the user nets the public 950 plus the margin; the protocol captures 1100 - 1091 = 9. + #[case::exclusive_with_higher_gas((1000, 950), (1100, 960), (1091, 951, Some(9)))] // Gas-cheaper exclusive route: the public amount is the larger bound (committed = 1000); // the user keeps the // gas saving (nets 960) and the protocol captures 100. #[case::exclusive_with_lower_gas((1000, 950), (1100, 1060), (1000, 960, Some(100)))] + // 1 bps of 1_000_000 is 100: a candidate only 50 better than the public route is rejected. + #[case::exclusive_below_margin((1_000_000, 1_000_000), (1_000_050, 1_000_050), + (1_000_000, 1_000_000, None))] + // Exactly at the margin: the route is quoted and the whole 100 goes to the user, surplus 0. + #[case::exclusive_at_margin((1_000_000, 1_000_000), (1_000_100, 1_000_100), + (1_000_100, 1_000_100, Some(0)))] + // Above the margin: the user gets the 100, the protocol captures the remaining 400. + #[case::exclusive_above_margin((1_000_000, 1_000_000), (1_000_500, 1_000_500), + (1_000_100, 1_000_100, Some(400)))] fn test_combine_head_selection( #[case] public: (u64, u64), #[case] exclusive: (u64, u64), @@ -1956,14 +1995,14 @@ mod tests { .expect("should have an exclusive swap"); // committed_leg = leg.amount_out * committed_route_out / realized_route_out - // = 1000 * 900 / 1000 = 900 - assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(900u64)),); + // = 1000 * 901 / 1000 = 901 + assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(901u64)),); } #[test] fn test_combine_committed_leg_deduction() { - // leg = 995, committed = 900, realized = 1000: the route's excess (100) is deducted from - // the exclusive leg in full — committed_leg = 995 − 100 = 895, exactly, no rounding. + // leg = 995, committed = 901, realized = 1000: the route's excess (99) is deducted from + // the exclusive leg in full — committed_leg = 995 − 99 = 896, exactly, no rounding. let responses = OrderResponses { order_id: "test-order".to_string(), quotes: vec![ @@ -2002,7 +2041,7 @@ mod tests { .iter() .find(|s| policy.is_exclusive(s.protocol_component())) .expect("should have an exclusive swap"); - assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(895u64))); + assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(896u64))); } #[test] @@ -2025,9 +2064,9 @@ mod tests { #[test] fn test_combine_split_route_attribution() { - // Split route: public branch 600 + exclusive branch 500 = 1100 realized vs 1000 - // committed (zero gas). Only the exclusive leg is stamped; the public branch flows to - // the user untouched. + // Split route: public branch 600 + exclusive branch 500 = 1100 realized vs 1001 + // committed (zero gas, 1 unit improvement margin). Only the exclusive leg is stamped; + // the public branch flows to the user untouched. let responses = OrderResponses { order_id: "test-order".to_string(), quotes: vec![ @@ -2058,7 +2097,7 @@ mod tests { Some(&policy), ); - assert_eq!(*combined[0].amount_out(), BigUint::from(1000u64)); + assert_eq!(*combined[0].amount_out(), BigUint::from(1001u64)); let route = combined[0] .route() .expect("surplus quote should have a route"); @@ -2070,15 +2109,15 @@ mod tests { assert_eq!(public_leg.committed_amount_out(), None); // The public branch (600) pays out in full, so the entire excess - // (1100 − 1000 = 100) is deducted from the exclusive leg: committed_leg = 500 − 100 = - // 400. The user receives 600 + 400 = 1000 (exactly the committed amount) and the hook - // captures all 100. + // (1100 − 1001 = 99) is deducted from the exclusive leg: committed_leg = 500 − 99 = + // 401. The user receives 600 + 401 = 1001 (exactly the committed amount) and the hook + // captures all 99. let exclusive_leg = route .swaps() .iter() .find(|s| policy.is_exclusive(s.protocol_component())) .expect("should have an exclusive swap"); - assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(400u64))); + assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(401u64))); } /// Builds an `OrderResponses` where both quotes carry explicit `amount_out_net_gas`. diff --git a/tools/hindsight/CLAUDE.md b/tools/hindsight/CLAUDE.md index 0a2d8246..2816d973 100644 --- a/tools/hindsight/CLAUDE.md +++ b/tools/hindsight/CLAUDE.md @@ -27,13 +27,16 @@ and takes neither. back-of-block (state N), and emits `RangeComparison` JSONL records. Exposes a Prometheus metrics endpoint (`--metrics-port`). `--max-lag-blocks` (default 100, ~20 min on mainnet) bounds how far it may fall behind chain head before rebuilding the solver. + `--propamm-pair ` additionally injects a mock PropAMM pool (see below). - **`report`** — Offline: read the `comparisons-YYYY-MM-DD.jsonl` files a `monitor` run wrote (`--comparisons-dir`) and render a single self-contained HTML file (`-o`, defaults to `/report.html`) with the dashboard's value views — the headline Fynd savings, win rate, and median savings bps; the verdict split by trade count and by volume; per-solver/venue breakdowns; top-saving trades; and the unsolved token tail. `--venue ` (repeatable, case-insensitive) - restricts the report to those venues. No chain, Tycho, or network access. + restricts the report to those venues. No chain, Tycho, or network access. Records from a + `--propamm-pair` run also get a "Mock PropAMM" section (winrate, captured flow, fee headroom, and + a per-order-pair breakdown). ## Environment @@ -43,6 +46,9 @@ and takes neither. | `ALLIUM_API_KEY` | Allium API key (`verify` only) | | `ALLIUM_QUERY_ID` | Saved Allium query ID (`verify` only) | | `HINDSIGHT_REGISTRY` | Override path for the decoder address-book TOML | +| `PROPAMM_PAIR` | Token pair the mock PropAMM mirrors, comma-separated (`monitor` only) | +| `PROPAMM_OFFSETS_BPS` | Price offsets in bps off the public best route, e.g. `-5,0,5` | +| `PROPAMM_PROBE_UNITS` | Trade size used to pick which real pool the mock mirrors | ## Architecture @@ -101,6 +107,56 @@ self-contained HTML file. | `aggregate.rs` | Pure aggregations over the records (verdicts, coverage, savings, per-group, movers) | | `html.rs` | Renders the aggregates to a self-contained HTML file (inline CSS, `
` bars, no assets) | +### Mock PropAMM (`src/propamm/`) + +Test scaffolding for ENG-6157 — sizes what a dynamic-underbidding PropAMM pool would win before the +pool exists. Off unless `monitor --propamm-pair` is set. + +| File | Purpose | +|---|---| +| `mod.rs` | `Injector` — writes a synthetic exclusive component into the running solver's `MarketState` once per block and announces it on the market-event channel | +| `mirror.rs` | `MirrorPool` — a `ProtocolSim` that delegates to the best real pool for the pair and scales its price by `--propamm-price-pct`, charging no fee | +| `report.rs` | Per-order outcomes and run totals; `Record` is what lands in the comparisons JSONL | + +Each order on the mirrored pair is solved **twice**: once with the mock neutralised (scaled to one +part per million, so it stays in the graph but loses every comparison), which yields the public best +route Fynd would otherwise have quoted; then again with the mock rescaled so its output lands +`--propamm-offsets-bps` off that number. So an offset means "this much better than the route Fynd +would have quoted", not "this much better than some single pool". + +That makes every calibrated order an assertion. The report groups them by offset and judges each +group against the behaviour its price implies: + +| offset | expectation | +|---|---| +| below market | never selected — the router requires a strict beat | +| at market | can only win on gas, and then there is no surplus, so the fee must be zero | +| above market | the fee taken cannot exceed the offset, since the offset is all the surplus there is | + +A group with no selections above market is reported as *no data*, not a failure: winning there +depends on gas as well as price. Orders off the mirrored pair carry no offset and form no group — +calibration is only exact when the mock serves the whole order in one hop. + +Fynd's existing exclusive-access routing does the rest: `FyndBuilder::exclusivity_policy` hides the +mock from every configured worker pool, and each pool is twinned with a `liquidity_scope = "all"` +copy that sees it. Because the router pins a surplus quote's `amount_out` to the public commitment, +the mock never changes hindsight's own win/loss verdicts — it only adds this second measurement. + +Requires `fynd-core`'s `experimental` feature for `Solver::market_event_sender`. Not for production: +the mock prices a pool that does not exist on chain, so any calldata it produces is unexecutable. + +Two things to get right when running it: + +- **Set `EXCLUSIVE_SWAP_CONTROLLER_KEY`** (any throwaway key — nothing is executed). The encoder + fails fast on an exclusive leg with no signer, which turns every win into a failed quote and + silently reports a zero winrate. +- **Pick a pair that has flow.** Only orders whose own pair is the mirrored one get calibrated, and + those are a small slice of settled flow — 40 mainnet blocks yielded 9 calibrated orders on + ETH/USDT. On ethereum, ETH/USDC carries roughly twice ETH/USDT's volume; check a run's per-pair + table before committing to a long one. Filling the groups is a matter of blocks, so budget for it. +- **`--min-tvl 10`** is mandatory against `tycho-fynd-ethereum`, which rejects any other value with + `tvl_gt must be == 10`. + ### Verdict model Each re-solved trade produces a `top` (optimistic, state N-1) and `back` (pessimistic, state N) diff --git a/tools/hindsight/Cargo.toml b/tools/hindsight/Cargo.toml index e74f6c1f..b80b542d 100644 --- a/tools/hindsight/Cargo.toml +++ b/tools/hindsight/Cargo.toml @@ -50,10 +50,15 @@ anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +# Timestamps on the mock PropAMM component +chrono = { workspace = true } + # Serialization serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } +# Registers the mock PropAMM pool state as a `Box` implementation +typetag = { workspace = true } [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/tools/hindsight/src/main.rs b/tools/hindsight/src/main.rs index 6047fa64..ceeb6e35 100644 --- a/tools/hindsight/src/main.rs +++ b/tools/hindsight/src/main.rs @@ -1,4 +1,5 @@ mod decoder; +mod propamm; mod report; mod resolve; mod telemetry; diff --git a/tools/hindsight/src/propamm/mirror.rs b/tools/hindsight/src/propamm/mirror.rs new file mode 100644 index 00000000..34648478 --- /dev/null +++ b/tools/hindsight/src/propamm/mirror.rs @@ -0,0 +1,523 @@ +//! A [`ProtocolSim`] that mirrors another pool's curve at a configurable, fee-free price. +//! +//! The `PropAMM` pool that will go live is an Ekubo V3 pool whose base fee is 0 and whose per-swap +//! fee Fynd signs. This type stands in for the fee-free half of that: it mirrors a real pool's live +//! curve and scales the price by [`MirrorPool::from_price_pct`]'s percentage. It charges no fee, so +//! whatever the router later finds above the public commitment is exactly the fee headroom the +//! signed extension could charge and still win the trade. +//! +//! Wrapping rather than patching the mirrored state keeps this protocol-agnostic. Every concrete +//! state (`UniswapV3State`, `EkuboV3State`, a `vm:` pool) stores its fee somewhere different, so +//! rewriting a fee field would mean per-protocol surgery that breaks whenever +//! `tycho-simulation` changes shape. Delegating and scaling the result works for all of them. +//! +//! `query_pool_swap` is deliberately *not* implemented, so the trait default's +//! `"query_pool_swap not implemented"` error makes `PoolDepthComputation` fall back to its Brent +//! solver, which goes through [`ProtocolSim::get_amount_out`] and therefore sees the scaled price. +//! An implementation that delegated would report the mirrored pool's unscaled depth. + +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use tycho_simulation::{ + tycho_common::{models::token::Token, Bytes}, + tycho_core::simulation::{ + errors::{SimulationError, TransitionError}, + protocol_sim::{Balances, GetAmountOutResult, ProtocolSim}, + }, +}; + +/// Denominator of the price scale: the mirrored price is expressed in parts per million, so a +/// percentage resolves to 0.01 bps. `u32` throughout, which converts to `f64` losslessly. +const PRICE_SCALE: u32 = 1_000_000; + +/// Mirrors `inner`'s curve at `price_ppm` parts per million of its price, charging no fee. +/// +/// `price_ppm == PRICE_SCALE` mirrors the source exactly — the control case. Above it the mock +/// quotes better than the best real pool, below it worse. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct MirrorPool { + /// The mirrored pool's live state. Typetag-serialized, so any concrete state works. + inner: Box, + /// The mock's fee-free price as parts per million of the mirrored pool's price. + price_ppm: u32, +} + +impl MirrorPool { + /// Wraps `inner`, quoting at `price_pct` percent of its price. + /// + /// A percentage outside `[0, 400]` is clamped: the mock is a plausibility probe, and a price + /// hundreds of times the market's would only produce routes no real pool could ever fill. + pub(crate) fn from_price_pct(inner: Box, price_pct: f64) -> Self { + Self { inner, price_ppm: price_pct_to_ppm(price_pct) } + } + + /// Wraps `inner` at an explicit parts-per-million scale, clamped to the same range. + /// + /// Used by the per-order calibration, which solves for the scale that lands the mock's output + /// on a target rather than expressing it as a percentage. + pub(crate) fn from_scale_ppm(inner: Box, scale_ppm: u32) -> Self { + Self { inner, price_ppm: scale_ppm.min(4 * PRICE_SCALE) } + } + + /// The scale that makes this pool's output for `amount_in` land on `target_out`, in parts per + /// million, or `None` when `inner` cannot price the pair. + /// + /// Rounds down, so the calibrated output never overshoots the target — an overshoot at the + /// zero-offset group would manufacture surplus the test is asserting is absent. + pub(crate) fn scale_for_target( + inner: &dyn ProtocolSim, + amount_in: BigUint, + token_in: &Token, + token_out: &Token, + target_out: &BigUint, + ) -> Option { + let unscaled = inner + .get_amount_out(amount_in, token_in, token_out) + .ok()? + .amount; + if unscaled == BigUint::ZERO { + return None; + } + let ppm = target_out * BigUint::from(PRICE_SCALE) / unscaled; + u32::try_from(ppm) + .ok() + .map(|ppm| ppm.min(4 * PRICE_SCALE)) + } + + /// The scale that makes this pool quote essentially nothing, so the router can never select it. + /// + /// One part per million of the mirrored price, not zero: a zero scale makes `spot_price` error, + /// which would keep the pool out of the graph entirely rather than merely out of the winning + /// route — and the point of neutralising it is to read the public best route *with the mock's + /// edge still present*. + pub(crate) const fn neutral_scale_ppm() -> u32 { + 1 + } + + /// The mock's fee-free price as a fraction of the mirrored pool's price. + pub(crate) fn price_factor(&self) -> f64 { + f64::from(self.price_ppm) / f64::from(PRICE_SCALE) + } + + /// Scales an output amount by the configured price, rounding down. + fn scale(&self, amount: &BigUint) -> BigUint { + amount * BigUint::from(self.price_ppm) / BigUint::from(PRICE_SCALE) + } +} + +/// Converts a percentage of the mirrored price into parts per million, clamped to a sane range. +/// +/// A non-finite input becomes `PRICE_SCALE` (mirror exactly), which is the safe default: it makes +/// the mock unable to win rather than able to win by an arbitrary amount. +// Truncation and sign loss are impossible after the clamp: the value is finite and in [0, 4e6]. +#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn price_pct_to_ppm(price_pct: f64) -> u32 { + if !price_pct.is_finite() { + return PRICE_SCALE; + } + let ppm = (price_pct / 100.0 * f64::from(PRICE_SCALE)).clamp(0.0, 4.0 * f64::from(PRICE_SCALE)); + ppm.round() as u32 +} + +#[typetag::serde] +impl ProtocolSim for MirrorPool { + /// Zero: the mock is the fee-free curve, matching the live pool's base fee of 0. The per-swap + /// fee is the headroom the router discovers afterwards, not something priced in here. + /// + /// Reporting zero also sidesteps `ProtocolSim::fee`'s documented panic on protocols with + /// asymmetric fees (Uniswap V4, Rocketpool), which delegating to `inner` would inherit. + fn fee(&self) -> f64 { + 0.0 + } + + /// The mirrored pool's spot price, scaled. `spot_price` is quote-per-base — a cost — so a + /// better price is a smaller number, hence the division. + fn spot_price(&self, base: &Token, quote: &Token) -> Result { + let price = self.inner.spot_price(base, quote)?; + let factor = self.price_factor(); + if factor <= 0.0 { + return Err(SimulationError::FatalError( + "mirrored price factor is zero; the mock pool cannot quote".to_string(), + )); + } + Ok(price / factor) + } + + fn get_amount_out( + &self, + amount_in: BigUint, + token_in: &Token, + token_out: &Token, + ) -> Result { + let result = self + .inner + .get_amount_out(amount_in, token_in, token_out)?; + Ok(GetAmountOutResult { + amount: self.scale(&result.amount), + gas: result.gas, + new_state: Box::new(Self { inner: result.new_state, price_ppm: self.price_ppm }), + }) + } + + /// The mirrored pool's limits, with the output bound scaled. The input bound is unchanged: the + /// price scale is a price, not extra liquidity. + fn get_limits( + &self, + sell_token: Bytes, + buy_token: Bytes, + ) -> Result<(BigUint, BigUint), SimulationError> { + let (max_in, max_out) = self + .inner + .get_limits(sell_token, buy_token)?; + Ok((max_in, self.scale(&max_out))) + } + + fn delta_transition( + &mut self, + delta: tycho_simulation::tycho_core::dto::ProtocolStateDelta, + tokens: &std::collections::HashMap, + balances: &Balances, + ) -> Result<(), TransitionError> { + self.inner + .delta_transition(delta, tokens, balances) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn eq(&self, other: &dyn ProtocolSim) -> bool { + other + .as_any() + .downcast_ref::() + .is_some_and(|other| { + self.price_ppm == other.price_ppm && self.inner.eq(other.inner.as_ref()) + }) + } +} + +#[cfg(test)] +mod tests { + use tycho_simulation::tycho_common::models::Chain; + + use super::*; + + /// A constant-price pool: one unit in, `rate` units out, so the price scale is the only thing + /// that moves the output. + #[derive(Debug, Clone, Serialize, Deserialize)] + struct FlatPool { + rate: u32, + } + + #[typetag::serde] + impl ProtocolSim for FlatPool { + fn fee(&self) -> f64 { + 0.003 + } + + fn spot_price(&self, _base: &Token, _quote: &Token) -> Result { + Ok(f64::from(self.rate)) + } + + fn get_amount_out( + &self, + amount_in: BigUint, + _token_in: &Token, + _token_out: &Token, + ) -> Result { + Ok(GetAmountOutResult { + amount: amount_in * BigUint::from(self.rate), + gas: BigUint::from(100_000u64), + new_state: Box::new(self.clone()), + }) + } + + fn get_limits( + &self, + _sell_token: Bytes, + _buy_token: Bytes, + ) -> Result<(BigUint, BigUint), SimulationError> { + Ok((BigUint::from(1_000u64), BigUint::from(1_000u64 * u64::from(self.rate)))) + } + + fn delta_transition( + &mut self, + _delta: tycho_simulation::tycho_core::dto::ProtocolStateDelta, + _tokens: &std::collections::HashMap, + _balances: &Balances, + ) -> Result<(), TransitionError> { + Ok(()) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn eq(&self, other: &dyn ProtocolSim) -> bool { + other + .as_any() + .downcast_ref::() + .is_some_and(|other| self.rate == other.rate) + } + } + + fn token(symbol: &str) -> Token { + Token { + address: Bytes::from(vec![0x11; 20]), + symbol: symbol.to_string(), + decimals: 18, + tax: 0, + gas: vec![], + chain: Chain::Ethereum, + quality: 100, + } + } + + fn mirror(price_pct: f64) -> MirrorPool { + MirrorPool::from_price_pct(Box::new(FlatPool { rate: 1_000 }), price_pct) + } + + #[test] + fn test_get_amount_out_scales_by_the_configured_price() { + // (price_pct, expected output for 1_000 in at rate 1_000) + for (price_pct, expected) in [ + (100.0, 1_000_000u64), + (100.01, 1_000_100), + (100.05, 1_000_500), + (100.3, 1_003_000), + (99.9, 999_000), + ] { + let out = mirror(price_pct) + .get_amount_out(BigUint::from(1_000u64), &token("A"), &token("B")) + .expect("flat pool always quotes") + .amount; + assert_eq!(out, BigUint::from(expected), "price_pct = {price_pct}"); + } + } + + #[test] + fn test_hundred_percent_mirrors_the_source_exactly() { + // The control case the harness relies on: at 100% the mock cannot strictly beat its source, + // so the router must not select it over an equally-priced public route. + let source = FlatPool { rate: 1_000 }; + let amount = BigUint::from(7_777u64); + let mirrored = mirror(100.0) + .get_amount_out(amount.clone(), &token("A"), &token("B")) + .expect("mirror quotes") + .amount; + let direct = source + .get_amount_out(amount, &token("A"), &token("B")) + .expect("source quotes") + .amount; + assert_eq!(mirrored, direct); + } + + #[test] + fn test_fee_is_zero_so_headroom_is_measured_not_assumed() { + // The mock is the fee-free curve; the fee it could charge is what the router finds above + // the public commitment, not a number baked in here. + assert!(mirror(100.5).fee().abs() < f64::EPSILON); + } + + #[test] + fn test_new_state_keeps_the_price_scale() { + // Split routes chain swaps through `new_state`; if the scale were dropped there, only the + // first leg would be repriced. + let post_swap = mirror(100.5) + .get_amount_out(BigUint::from(1_000u64), &token("A"), &token("B")) + .expect("mirror quotes") + .new_state; + let out = post_swap + .get_amount_out(BigUint::from(1_000u64), &token("A"), &token("B")) + .expect("post-swap state quotes") + .amount; + assert_eq!(out, BigUint::from(1_005_000u64)); + } + + #[test] + fn test_spot_price_improves_with_the_price_scale() { + // spot_price is quote-per-base, so a better price is a lower number. + let base = mirror(100.0) + .spot_price(&token("A"), &token("B")) + .expect("price"); + let scaled = mirror(101.0) + .spot_price(&token("A"), &token("B")) + .expect("price"); + assert!(scaled < base, "{scaled} should undercut {base}"); + assert!((scaled - base / 1.01).abs() < 1e-9); + } + + #[test] + fn test_spot_price_errs_at_a_zero_price() { + // A zero price would divide by zero. Erroring keeps the pool out of routing instead of + // producing an infinite spot price that poisons every edge weight derived from it. + assert!(mirror(0.0) + .spot_price(&token("A"), &token("B")) + .is_err()); + } + + #[test] + fn test_get_limits_scales_output_bound_only() { + let (max_in, max_out) = mirror(101.0) + .get_limits(Bytes::from(vec![0x11; 20]), Bytes::from(vec![0x22; 20])) + .expect("limits"); + assert_eq!(max_in, BigUint::from(1_000u64)); + assert_eq!(max_out, BigUint::from(1_010_000u64)); + } + + #[test] + fn test_price_pct_clamps_and_defaults_safely() { + assert_eq!(price_pct_to_ppm(100.0), PRICE_SCALE); + assert_eq!(price_pct_to_ppm(-5.0), 0, "a negative price floors at zero"); + assert_eq!(price_pct_to_ppm(10_000.0), 4 * PRICE_SCALE, "an absurd price is capped"); + // NaN must mirror exactly rather than win by an arbitrary amount. + assert_eq!(price_pct_to_ppm(f64::NAN), PRICE_SCALE); + assert_eq!(price_pct_to_ppm(f64::INFINITY), PRICE_SCALE); + } + + #[test] + fn test_query_pool_swap_reports_unimplemented() { + // PoolDepthComputation matches this exact message to fall back to its Brent solver, which + // reads amounts through get_amount_out and therefore sees the price scale. Delegating here + // would instead report the mirrored pool's unscaled depth. + use tycho_simulation::tycho_core::simulation::protocol_sim::{ + Price, QueryPoolSwapParams, SwapConstraint, + }; + + let params = QueryPoolSwapParams::new( + token("A"), + token("B"), + SwapConstraint::TradeLimitPrice { + limit: Price::new(BigUint::from(1u64), BigUint::from(1u64)), + tolerance: 0.0, + min_amount_in: None, + max_amount_in: None, + }, + ); + + let result = mirror(100.5).query_pool_swap(¶ms); + assert!( + matches!(&result, Err(SimulationError::FatalError(msg)) + if msg == "query_pool_swap not implemented"), + "expected the trait default's fatal error" + ); + } + + #[test] + fn test_gas_is_the_mirrored_pools_gas() { + // The PropAMM hop costs roughly what its source hop costs; inventing a gas number would + // bias amount_out_net_gas and therefore the win/loss comparison. + let result = mirror(105.0) + .get_amount_out(BigUint::from(1u64), &token("A"), &token("B")) + .expect("mirror quotes"); + assert_eq!(result.gas, BigUint::from(100_000u64)); + } + + #[test] + fn test_scale_for_target_lands_the_output_on_the_target() { + // The whole calibration rests on this: solve for the scale, apply it, land on the target. + let source = FlatPool { rate: 1_000 }; + let amount = BigUint::from(1_000u64); + for offset_bps in [-50i64, -5, 0, 5, 50, 500] { + let unscaled = BigUint::from(1_000_000u64); + // Every offset in the table is well above -10_000 bps, so the sum is positive. + let shifted = (10_000 + offset_bps).unsigned_abs(); + let target = &unscaled * BigUint::from(shifted) / BigUint::from(10_000u64); + let scale = MirrorPool::scale_for_target( + &source, + amount.clone(), + &token("A"), + &token("B"), + &target, + ) + .expect("a flat pool can always be scaled"); + + let realized = MirrorPool::from_scale_ppm(Box::new(source.clone()), scale) + .get_amount_out(amount.clone(), &token("A"), &token("B")) + .expect("scaled pool quotes") + .amount; + // Rounding down twice can cost a couple of atomic units on a 1e6 output; the offset it + // represents is far below the 0.5 bps the report tolerates. + let realized = i128::try_from(realized).unwrap(); + let target = i128::try_from(target).unwrap(); + assert!( + (realized - target).abs() <= 2, + "offset {offset_bps}: landed on {realized}, wanted {target}" + ); + } + } + + #[test] + fn test_scale_for_target_rounds_down_so_it_never_overshoots() { + // An overshoot at the at-market group would manufacture surplus the test asserts is absent. + let source = FlatPool { rate: 3 }; + let amount = BigUint::from(7u64); + // Unscaled output is 21; ask for 10, which is not a whole number of parts per million of + // it. + let target = BigUint::from(10u64); + let scale = MirrorPool::scale_for_target( + &source, + amount.clone(), + &token("A"), + &token("B"), + &target, + ) + .unwrap(); + let realized = MirrorPool::from_scale_ppm(Box::new(source), scale) + .get_amount_out(amount, &token("A"), &token("B")) + .unwrap() + .amount; + assert!(realized <= target, "{realized} overshot {target}"); + } + + #[test] + fn test_neutral_scale_quotes_essentially_nothing_but_still_prices() { + // Neutralising must keep the pool in the graph — the point is to read the public best route + // with the mock's edge present — so spot_price has to stay finite. + let neutral = MirrorPool::from_scale_ppm( + Box::new(FlatPool { rate: 1_000 }), + MirrorPool::neutral_scale_ppm(), + ); + let out = neutral + .get_amount_out(BigUint::from(1_000u64), &token("A"), &token("B")) + .expect("still quotes") + .amount; + // One part per million of 1_000_000. + assert_eq!(out, BigUint::from(1u64)); + let price = neutral + .spot_price(&token("A"), &token("B")) + .expect("neutralised pool still has a finite price"); + assert!(price.is_finite() && price > 0.0, "got {price}"); + } + + #[test] + fn test_scale_for_target_declines_a_source_that_cannot_price() { + // A source quoting zero has no scale that reaches a positive target; the caller must leave + // the order uncalibrated rather than divide by zero. + let dead = FlatPool { rate: 0 }; + assert!(MirrorPool::scale_for_target( + &dead, + BigUint::from(1_000u64), + &token("A"), + &token("B"), + &BigUint::from(500u64), + ) + .is_none()); + } +} diff --git a/tools/hindsight/src/propamm/mod.rs b/tools/hindsight/src/propamm/mod.rs new file mode 100644 index 00000000..3b1ef5e1 --- /dev/null +++ b/tools/hindsight/src/propamm/mod.rs @@ -0,0 +1,670 @@ +//! Mock `PropAMM` pool for measuring what dynamic underbidding would win, before the pool exists. +//! +//! # What this measures +//! +//! The live `PropAMM` will be an Ekubo V3 pool at base fee 0 whose per-swap fee Fynd signs just low +//! enough to underbid the best competing route. Two independent quantities decide whether that +//! works, so the harness keeps them separate: +//! +//! 1. **The pool's fee-free price**, set by `--propamm-price-pct` as a percentage of the best real +//! pool's price for the pair. `100` means "the `PropAMM` holds exactly the best price we can +//! see"; `100.05` means it holds a price 5 bps better. This is the input — the assumption about +//! how the pool is positioned. +//! 2. **The fee it could charge and still win**, which the harness *measures*. The mock quotes at +//! the configured price with no fee at all, so whatever the router finds above the public +//! commitment is exactly the headroom the signed extension could take. That is reported per +//! trade and averaged over the run as `fee_headroom_bps`. +//! +//! So a run answers: "with the pool at N% of the market's best price, it would have won this share +//! of flow, and could have charged this much fee on top and still won it." +//! +//! Mechanically the harness inserts a synthetic component into the running solver's market state, +//! mirrors the best real pool for the pair onto it at the configured price (see +//! [`mirror::MirrorPool`]), and lets Fynd's existing exclusive-access routing do the rest: public +//! worker pools never see the mock, their exclusive-access twins do, and the router reports the +//! surplus whenever the mock route beats the public reference. +//! +//! # Why the market state, not the Tycho stream +//! +//! Rewriting each `Update` before it reaches `TychoFeed` would need a mirrored state cached across +//! blocks, because a pool only appears in `Update::states` on blocks where it changed. Writing +//! straight into [`MarketData`] avoids that entirely: `MarketState` holds the latest state for +//! every component regardless of which block last touched it, so every block has a source to +//! mirror. +//! +//! Two things the injection still has to get right: +//! +//! - **Announce the component, once.** Workers build their graph from `MarketEvent::MarketUpdated`, +//! so a component written into `MarketState` without an event is invisible to routing. The first +//! injection announces it as added; later ones as updated, which is also what drives incremental +//! recomputation of its spot price and depth. +//! - **Wait for derived data.** Edge weights come from spot prices. Solving in the gap between the +//! injected event and the recomputation it triggers would rank the mock on a stale weight, so +//! [`Injector::inject`] blocks until the mock's spot price is recomputed at the target block. +//! +//! # Not for production +//! +//! Scaffolding for ENG-6157. The mock prices a pool that does not exist on chain, so any calldata +//! it produces is unexecutable. + +mod mirror; +pub(crate) mod report; + +use std::{ + collections::HashMap, + str::FromStr, + time::{Duration, Instant}, +}; + +use chrono::NaiveDateTime; +use fynd_core::{feed::market_data::MarketData, MarketEvent, Solver}; +use mirror::MirrorPool; +use num_bigint::BigUint; +use tokio::sync::broadcast; +use tracing::{debug, info, warn}; +use tycho_simulation::{ + tycho_common::{ + models::{protocol::ProtocolComponent, token::Token, Address, Chain, ChangeType}, + Bytes, + }, + tycho_core::simulation::protocol_sim::ProtocolSim, +}; + +/// Component id of the mock pool. Address-shaped so anything that treats a component id as a pool +/// address stays well-formed, and recognisable in logs and JSONL. +pub(crate) const MOCK_COMPONENT_ID: &str = "0x9797979797979797979797979797979797979797"; + +/// Protocol system the mock reports. `ekubo_v3` is what the live pool will be, and it selects the +/// Ekubo swap encoder — the one path that knows how to carry a signed exclusive swap. +const MOCK_PROTOCOL_SYSTEM: &str = "ekubo_v3"; + +/// Placeholder for Ekubo's `SignedExclusiveSwap` extension, which is not deployed yet. Only the +/// encoder and the EIP-712 domain read it, so a placeholder produces a well-formed — but +/// deliberately unexecutable — payload. +const SIGNED_EXCLUSIVE_SWAP_ADDRESS: &str = "0x5519ed5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e"; + +/// How long to wait for the mock's spot price to be recomputed at the target block before solving +/// anyway. Generous: it competes with a whole block's incremental computation, not just the mock's. +const DERIVED_DATA_WAIT: Duration = Duration::from_secs(30); +/// Poll interval while waiting for that recomputation. +const DERIVED_DATA_POLL: Duration = Duration::from_millis(25); + +/// Which pool to mirror, and how far off the public best route to price it. +#[derive(Debug, Clone)] +pub(crate) struct MirrorConfig { + /// First token of the mirrored pair. + pub token_a: Address, + /// Second token of the mirrored pair. + pub token_b: Address, + /// Price offsets, in basis points relative to the public best route's output for the order + /// being solved. Orders on the mirrored pair cycle through these, so one run fills every + /// group. + /// + /// A negative offset prices the mock below the public market and must never be selected; zero + /// matches it exactly; a positive offset is the underbid the pool would offer. + pub offsets_bps: Vec, + /// `token_a` amount, in whole units, used to rank candidate source pools each block. Picking + /// by realized output at a representative size, rather than by TVL, keeps the mirror on + /// the pool that actually prices best for the sizes being re-solved. + pub probe_units: f64, + /// Chain the mock component reports. + pub chain: Chain, +} + +impl MirrorConfig { + /// Parses a `--propamm-pair` value of two comma-separated token addresses. + pub(crate) fn parse_pair(pair: &[String]) -> anyhow::Result<(Address, Address)> { + let [token_a, token_b] = pair else { + anyhow::bail!("--propamm-pair needs exactly two token addresses, got {}", pair.len()); + }; + Ok(( + Bytes::from_str(token_a) + .map_err(|e| anyhow::anyhow!("invalid token address {token_a}: {e}"))?, + Bytes::from_str(token_b) + .map_err(|e| anyhow::anyhow!("invalid token address {token_b}: {e}"))?, + )) + } + + /// Whether an order's tokens are exactly the mirrored pair, in either direction. + /// + /// Only these orders can be calibrated: the mock then serves the whole order in one hop, so its + /// route output is a known function of its scale. A multi-hop route's output is not, which is + /// why off-pair orders are solved normally and carry no offset label. + pub(crate) fn serves(&self, token_in: &Address, token_out: &Address) -> bool { + (*token_in == self.token_a && *token_out == self.token_b) || + (*token_in == self.token_b && *token_out == self.token_a) + } + + /// The offset this order should be priced at, cycling through `offsets_bps` by order index so + /// every group fills at the same rate. `None` when no offsets are configured. + pub(crate) fn offset_for(&self, order_index: u64) -> Option { + if self.offsets_bps.is_empty() { + return None; + } + let index = usize::try_from(order_index % self.offsets_bps.len() as u64).ok()?; + self.offsets_bps.get(index).copied() + } +} + +/// What one injection did, for logging. +#[derive(Debug)] +pub(crate) struct Injected { + /// Component id of the pool that was mirrored this block. + pub source_component: String, + /// The mirrored pool's price: `token_b` per whole unit of `token_a`, at the probe size. + /// Watching this across blocks is how you tell a live mirror from one stuck on a stale state. + pub source_price: f64, + /// Whether the mock's spot price was recomputed before the wait expired. + pub derived_data_ready: bool, + /// The mirrored pair as token symbols, e.g. `WETH/USDC`. + pub pair_label: String, +} + +/// Writes the mock pool into a running solver's market state, once per block. +pub(crate) struct Injector { + config: MirrorConfig, + events: broadcast::Sender, + /// Candidate source pools for the pair, resolved on first use. Pools for a pair change far + /// more slowly than blocks do, so this is refreshed only while still empty. + candidates: Vec, + /// Whether the mock has been announced as an added component. Announcing twice would re-add an + /// existing graph edge. + announced: bool, + /// This block's mirrored pool, kept so per-order calibration can simulate it at the order's + /// own size without re-reading the market. + source: Option, +} + +/// The mirrored pool and its tokens, cached for the current block. +struct CachedSource { + state: Box, + token_a: Token, + token_b: Token, +} + +impl CachedSource { + /// The pair's tokens ordered as `(in, out)` for an order, or `None` if the order is off-pair. + fn order_tokens(&self, token_in: &Address) -> Option<(&Token, &Token)> { + if *token_in == self.token_a.address { + Some((&self.token_a, &self.token_b)) + } else if *token_in == self.token_b.address { + Some((&self.token_b, &self.token_a)) + } else { + None + } + } +} + +/// What one per-order calibration set up. +#[derive(Debug)] +pub(crate) struct Calibration { + /// The public best route's output this offset was measured against. + pub public_best_out: BigUint, + /// The output the mock is expected to produce: `public_best_out` shifted by the offset. + pub target_out: BigUint, + /// The offset applied, in basis points. + pub offset_bps: i32, +} + +impl Calibration { + /// The offset the mock's target actually lands on, in basis points — the calibration's own + /// error check. Integer division and the source's own rounding mean this can differ from + /// `offset_bps` by a fraction of a bps; a large gap means the source could not hold the + /// target. + pub(crate) fn realized_offset_bps(&self) -> f64 { + let public = report::biguint_to_f64(&self.public_best_out); + if public <= 0.0 { + return 0.0; + } + (report::biguint_to_f64(&self.target_out) - public) / public * 10_000.0 + } +} + +impl Injector { + /// Creates an injector publishing on the solver's market-event channel. + pub(crate) fn new(solver: &Solver, config: MirrorConfig) -> Self { + Self { + config, + events: solver.market_event_sender(), + candidates: Vec::new(), + announced: false, + source: None, + } + } + + /// Mirrors the best source pool onto the mock component for `block`. + /// + /// Returns `Ok(None)` when no pool for the configured pair carries state yet — early blocks of + /// a fresh feed, or a pair that simply is not indexed. + pub(crate) async fn inject( + &mut self, + solver: &Solver, + block: u64, + ) -> anyhow::Result> { + let market = solver.market_data(); + + if self.candidates.is_empty() { + self.candidates = find_candidates(&market, &self.config).await; + if self.candidates.is_empty() { + return Ok(None); + } + info!( + candidates = self.candidates.len(), + "resolved candidate source pools for the mirrored pair" + ); + } + + let Some(source) = best_source(&market, &self.candidates, &self.config).await else { + return Ok(None); + }; + + // The block's baseline: the mock mirrors the source exactly. Per-order calibration rescales + // it afterwards, but derived data is computed against this state, so an unscaled mirror + // keeps the mock's edge weight in the same range as the real pools it competes + // with. + let mirrored: Box = + Box::new(MirrorPool::from_price_pct(source.state.clone_box(), 100.0)); + let first_injection = !self.announced; + { + let mut state = market.write().await; + if first_injection { + state.upsert_components([mock_component(&self.config)]); + } + state.update_states([(MOCK_COMPONENT_ID.to_string(), mirrored)]); + } + + let event = if first_injection { + self.announced = true; + MarketEvent::MarketUpdated { + added_components: HashMap::from([( + MOCK_COMPONENT_ID.to_string(), + vec![self.config.token_a.clone(), self.config.token_b.clone()], + )]), + removed_components: Vec::new(), + updated_components: Vec::new(), + } + } else { + MarketEvent::MarketUpdated { + added_components: HashMap::new(), + removed_components: Vec::new(), + updated_components: vec![MOCK_COMPONENT_ID.to_string()], + } + }; + self.events + .send(event) + .map_err(|e| anyhow::anyhow!("no market-event receivers left: {e}"))?; + + let derived_data_ready = wait_for_derived_data(solver, block).await; + if !derived_data_ready { + warn!( + block, + "mock pool's spot price was not recomputed within {}s; its edge weight may be \ + stale for this block", + DERIVED_DATA_WAIT.as_secs() + ); + } + + debug!( + block, + source_component = source.component_id, + source_price = source.price, + "mirrored source pool onto the mock PropAMM component" + ); + let pair_label = source.pair_label.clone(); + self.source = Some(CachedSource { + state: source.state, + token_a: source.token_a, + token_b: source.token_b, + }); + Ok(Some(Injected { + source_component: source.component_id, + source_price: source.price, + derived_data_ready, + pair_label, + })) + } + + /// Rescales the mock so it quotes essentially nothing, and can therefore never be selected. + /// + /// This is how the harness reads the *public* best route while leaving the graph untouched: the + /// mock's edge stays in place, it just loses every comparison. Cheaper and less disruptive than + /// removing the component, which would mean a topology event and a graph rebuild per order. + pub(crate) async fn neutralize(&self, market: &MarketData) -> bool { + self.rescale(market, MirrorPool::neutral_scale_ppm()) + .await + } + + /// Rescales the mock so its output for this order lands `offset_bps` off `public_best_out`. + /// + /// Returns `None` when the order is off-pair, no source is cached yet, or the source cannot + /// price the order — in each case the caller leaves the order uncalibrated rather than + /// reporting an offset the mock is not actually holding. + /// + /// No derived-data wait here: the topology is unchanged and the scale moves the mock's price by + /// basis points, so its edge weight from the block's baseline injection is still the right + /// order of magnitude for pruning. Waiting per order would cost a recomputation cycle each + /// time. + pub(crate) async fn calibrate( + &self, + market: &MarketData, + token_in: &Address, + amount_in: &BigUint, + public_best_out: &BigUint, + offset_bps: i32, + ) -> Option { + let source = self.source.as_ref()?; + let (from, to) = source.order_tokens(token_in)?; + let target_out = shift_bps(public_best_out, offset_bps); + let scale = MirrorPool::scale_for_target( + source.state.as_ref(), + amount_in.clone(), + from, + to, + &target_out, + )?; + if !self.rescale(market, scale).await { + return None; + } + Some(Calibration { public_best_out: public_best_out.clone(), target_out, offset_bps }) + } + + /// Writes the mock's state at `scale_ppm`. Returns whether a source was cached to rescale. + async fn rescale(&self, market: &MarketData, scale_ppm: u32) -> bool { + let Some(source) = self.source.as_ref() else { + return false; + }; + let scaled: Box = + Box::new(MirrorPool::from_scale_ppm(source.state.clone_box(), scale_ppm)); + market + .write() + .await + .update_states([(MOCK_COMPONENT_ID.to_string(), scaled)]); + true + } +} + +/// Shifts an amount by `offset_bps`, saturating at zero. A negative offset below -10000 bps would +/// otherwise wrap; the mock's price is a ratio and cannot go negative. +fn shift_bps(amount: &BigUint, offset_bps: i32) -> BigUint { + const BPS: i64 = 10_000; + let scaled = i64::from(offset_bps).saturating_add(BPS); + if scaled <= 0 { + return BigUint::ZERO; + } + amount * BigUint::from(scaled.unsigned_abs()) / BigUint::from(BPS.unsigned_abs()) +} + +/// Component ids holding both tokens of the configured pair, excluding the mock itself. +/// +/// Multi-token pools (Curve, Balancer) qualify: the mirror only ever quotes the configured pair, so +/// what matters is that the source can price it. +async fn find_candidates(market: &MarketData, config: &MirrorConfig) -> Vec { + let state = market.read().await; + state + .component_topology() + .into_iter() + .filter(|(id, tokens)| { + id != MOCK_COMPONENT_ID && + tokens.contains(&config.token_a) && + tokens.contains(&config.token_b) + }) + .map(|(id, _)| id) + .collect() +} + +/// The real pool the mock mirrors for one block. +/// +/// Chosen by realized output at the probe size rather than by TVL, so the mirror tracks the pool +/// that actually prices the sizes being re-solved best. Candidates that cannot price the pair — no +/// state yet, or a simulation error — are skipped rather than failing the block: a single broken +/// pool must not stop the run. +struct BestSource { + /// Component id of the mirrored pool. + component_id: String, + /// A clone of its live state. + state: Box, + /// The price it quoted: `token_b` per whole unit of `token_a`. + price: f64, + /// The pair as token symbols, e.g. `WETH/USDC`. + pair_label: String, + /// First token of the pair, carried so calibration can simulate the source without a market + /// read. + token_a: Token, + /// Second token of the pair. + token_b: Token, +} + +/// The candidate pool the mock mirrors this block. +async fn best_source( + market: &MarketData, + candidates: &[String], + config: &MirrorConfig, +) -> Option { + let state = market.read().await; + let token_a = state + .get_token(&config.token_a)? + .clone(); + let token_b = state + .get_token(&config.token_b)? + .clone(); + + let probe_amount = to_atomic(config.probe_units, &token_a); + let mut best: Option<(String, Box, BigUint)> = None; + for id in candidates { + let Some(sim) = state.get_simulation_state(id) else { + continue; + }; + let Ok(quoted) = sim.get_amount_out(probe_amount.clone(), &token_a, &token_b) else { + continue; + }; + if best + .as_ref() + .is_none_or(|(_, _, best_out)| quoted.amount > *best_out) + { + best = Some((id.clone(), sim.clone_box(), quoted.amount)); + } + } + + let (component_id, state, probe_out) = best?; + let price = if config.probe_units > 0.0 { + to_units(&probe_out, &token_b) / config.probe_units + } else { + 0.0 + }; + Some(BestSource { + component_id, + state, + price, + pair_label: format!("{}/{}", token_symbol(&token_a), token_symbol(&token_b)), + token_a, + token_b, + }) +} + +/// Blocks until the mock's spot price has been recomputed at `block`, or the wait expires. +/// +/// Returns whether it landed in time. Both conditions matter: the entry proves the mock entered the +/// computation at all, and the block proves the entry is this block's, not the previous one's. +async fn wait_for_derived_data(solver: &Solver, block: u64) -> bool { + let started = Instant::now(); + let derived = solver.derived_data(); + loop { + { + let guard = derived.read().await; + let computed_at_block = guard.spot_prices_block() == Some(block); + let includes_mock = guard + .spot_prices() + .is_some_and(|prices| { + prices + .keys() + .any(|(component_id, _, _)| component_id == MOCK_COMPONENT_ID) + }); + if computed_at_block && includes_mock { + return true; + } + } + if started.elapsed() >= DERIVED_DATA_WAIT { + return false; + } + tokio::time::sleep(DERIVED_DATA_POLL).await; + } +} + +/// The mock's `ProtocolComponent`. +/// +/// The three static attributes are what `EkuboV3SwapEncoder` and the exclusive-swap signer read: +/// `extension` (20 bytes), `fee` (8-byte big-endian `u64`, zero because the `PropAMM`'s base fee is +/// zero and the per-swap fee arrives in the signature), and `pool_type_config` (4 bytes). +fn mock_component(config: &MirrorConfig) -> ProtocolComponent { + let extension = Bytes::from_str(SIGNED_EXCLUSIVE_SWAP_ADDRESS) + .expect("the extension placeholder is a valid address literal"); + ProtocolComponent { + id: MOCK_COMPONENT_ID.to_string(), + protocol_system: MOCK_PROTOCOL_SYSTEM.to_string(), + protocol_type_name: "swap".to_string(), + chain: config.chain, + tokens: vec![config.token_a.clone(), config.token_b.clone()], + static_attributes: HashMap::from([ + ("extension".to_string(), extension), + ("fee".to_string(), Bytes::from(0u64)), + ("pool_type_config".to_string(), Bytes::from(0u32)), + ]), + change: ChangeType::default(), + creation_tx: Bytes::default(), + created_at: NaiveDateTime::default(), + contract_addresses: Vec::new(), + } +} + +/// Whether a component is the mock `PropAMM` pool — the predicate the solver's +/// [`ExclusivityPolicy`](fynd_core) is built from. +pub(crate) fn is_mock_component(component: &ProtocolComponent) -> bool { + component.id == MOCK_COMPONENT_ID +} + +/// A token's display symbol. +/// +/// The zero address is native ETH by the convention this codebase already uses for it (the +/// decoder's `native_wrapper` hops, the `wrapped_native` registry entry). It carries no symbol of +/// its own in the token registry, where it would otherwise render as forty hex characters. +fn token_symbol(token: &Token) -> String { + if token + .address + .iter() + .all(|byte| *byte == 0) + { + return "ETH".to_string(); + } + token.symbol.clone() +} + +/// Formats a token amount in whole units, for logging. +pub(crate) fn to_units(amount: &BigUint, token: &Token) -> f64 { + report::biguint_to_f64(amount) / 10f64.powi(decimals_exponent(token)) +} + +/// Converts whole units into the token's atomic units. +/// +/// Returns zero for anything that cannot be represented — non-finite, sub-atomic, or beyond `u64`. +/// A zero probe makes every candidate quote zero, so the caller picks arbitrarily rather than +/// silently mirroring a pool chosen by a garbage number. +// Truncation and sign loss are impossible after the guard: the value is finite and in [1, 2^63). +// The bound is a power of two, so it is exactly representable and the comparison is not +// approximate. +#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn to_atomic(units: f64, token: &Token) -> BigUint { + let atomic = units * 10f64.powi(decimals_exponent(token)); + if !atomic.is_finite() || atomic < 1.0 || atomic >= 2f64.powi(63) { + return BigUint::ZERO; + } + BigUint::from(atomic as u64) +} + +/// A token's decimals as the exponent both conversions use, defaulting to 18 on an absurd value. +fn decimals_exponent(token: &Token) -> i32 { + i32::try_from(token.decimals).unwrap_or(18) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mock_component_carries_the_attributes_the_encoder_reads() { + let config = MirrorConfig { + token_a: Bytes::from(vec![0x11; 20]), + token_b: Bytes::from(vec![0x22; 20]), + offsets_bps: vec![-5, 0, 5], + probe_units: 1.0, + chain: Chain::Ethereum, + }; + let component = mock_component(&config); + + assert_eq!(component.protocol_system, MOCK_PROTOCOL_SYSTEM); + assert_eq!(component.static_attributes["extension"].len(), 20); + assert_eq!(component.static_attributes["fee"].len(), 8); + assert_eq!(component.static_attributes["pool_type_config"].len(), 4); + assert_eq!(component.tokens, vec![config.token_a, config.token_b]); + } + + #[test] + fn test_mock_component_declares_zero_base_fee() { + // The live pool's base fee must be 0 (Ekubo reverts with PoolFeeMustBeZero otherwise); the + // whole fee is carried in the signed payload. + let config = MirrorConfig { + token_a: Bytes::from(vec![0x11; 20]), + token_b: Bytes::from(vec![0x22; 20]), + offsets_bps: vec![-5, 0, 5], + probe_units: 1.0, + chain: Chain::Ethereum, + }; + assert_eq!(mock_component(&config).static_attributes["fee"], Bytes::from(0u64)); + } + + #[test] + fn test_is_mock_component_matches_only_the_mock() { + let config = MirrorConfig { + token_a: Bytes::from(vec![0x11; 20]), + token_b: Bytes::from(vec![0x22; 20]), + offsets_bps: vec![-5, 0, 5], + probe_units: 1.0, + chain: Chain::Ethereum, + }; + let mut real = mock_component(&config); + real.id = "0xdeadbeef".to_string(); + + assert!(is_mock_component(&mock_component(&config))); + assert!(!is_mock_component(&real)); + } + + #[test] + fn test_parse_pair_accepts_two_addresses() { + let pair = vec![ + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2".to_string(), + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + ]; + let (token_a, token_b) = MirrorConfig::parse_pair(&pair).expect("valid pair"); + assert_eq!(token_a.len(), 20); + assert_eq!(token_b.len(), 20); + } + + #[test] + fn test_parse_pair_rejects_wrong_arity() { + assert!(MirrorConfig::parse_pair(&["0x11".to_string()]).is_err()); + assert!(MirrorConfig::parse_pair(&[]).is_err()); + } + + #[test] + fn test_to_units_scales_by_decimals() { + let token = Token { + address: Bytes::from(vec![0x11; 20]), + symbol: "USDC".to_string(), + decimals: 6, + tax: 0, + gas: vec![], + chain: Chain::Ethereum, + quality: 100, + }; + assert!((to_units(&BigUint::from(2_500_000u64), &token) - 2.5).abs() < 1e-9); + } +} diff --git a/tools/hindsight/src/propamm/report.rs b/tools/hindsight/src/propamm/report.rs new file mode 100644 index 00000000..2dafe579 --- /dev/null +++ b/tools/hindsight/src/propamm/report.rs @@ -0,0 +1,456 @@ +//! Collects what the mock `PropAMM` pool won, block by block. +//! +//! The mock quotes at its configured fee-free price and charges nothing, so when the router selects +//! it, everything the route produces above the public commitment is **fee headroom**: the fee the +//! signed extension could have charged and still won the trade. The router already computes exactly +//! that split — [`OrderQuote::committed_amount_out`] is what the user is promised and +//! [`OrderQuote::surplus_amount`] is the excess — so this module only has to read it, express it in +//! bps, and total it up. +//! +//! Because the quoted `amount_out` is pinned to the commitment, the mock pool never changes +//! hindsight's own win/loss verdict. It only adds this second, orthogonal measurement. + +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, +}; + +use alloy::primitives::Address; +use fynd_core::OrderQuote; +use num_bigint::BigUint; +use serde::Serialize; + +use crate::propamm::MOCK_COMPONENT_ID; + +/// One re-solved order's mock-`PropAMM` outcome. +#[derive(Debug, Clone)] +pub(crate) struct Observation { + /// Order output token — the token both amounts below are denominated in, and the one their USD + /// valuation is taken against. The order's input token and size are not repeated here: the + /// comparison record this joins onto already carries them. + pub token_out: Address, + /// Whether the solver produced a successful quote at all. Unsuccessful solves are still + /// recorded so the sink stays index-aligned with the block's trades, which is how each + /// observation is joined back to its comparison record. + pub solved: bool, + /// Whether the winning route ran through the mock `PropAMM` pool. + pub won: bool, + /// The public-market output the user is committed to. `None` when the mock pool didn't win. + pub committed_amount_out: Option, + /// Output the mock produced above the commitment — the fee it could have charged. `None` when + /// it didn't win. + pub fee_headroom: Option, + /// The offset the mock was priced at for this order, in basis points relative to the public + /// best route. `None` for an order that was not calibrated — off the mirrored pair, or + /// unsolvable publicly, so there was no reference to price against. + pub offset_bps: Option, + /// The public best route's output this order's offset was measured against. + pub public_best_out: Option, + /// The same order's result with the mock neutralised — the "without `PropAMM`" world. + /// + /// The two-pass calibration already solves that world to find the reference price, so keeping + /// its amounts turns every calibrated order into a controlled A/B: same block, same state, + /// same order, the mock's presence the only difference. + pub without: Option, +} + +/// One order's result with the mock neutralised. +#[derive(Debug, Clone)] +pub(crate) struct PublicOnly { + /// Output of the best public-only route. + pub amount_out: BigUint, + /// That output net of the route's gas. + pub amount_out_net_gas: BigUint, +} + +impl Observation { + /// Reads a re-solved quote's mock-`PropAMM` outcome. + /// + /// A win is a winning route that contains the mock component — not merely a non-empty surplus, + /// which can legitimately round to zero on a marginal beat. + pub(crate) fn from_quote(quote: &OrderQuote, token_out: Address) -> Self { + let solved = quote.status() == fynd_core::types::QuoteStatus::Success; + let won = solved && + quote.route().is_some_and(|route| { + route + .swaps() + .iter() + .any(|swap| swap.component_id() == MOCK_COMPONENT_ID) + }); + Self { + token_out, + solved, + won, + committed_amount_out: quote.committed_amount_out().cloned(), + fee_headroom: quote.surplus_amount().cloned(), + offset_bps: None, + public_best_out: None, + without: None, + } + } + + /// Labels this observation with the calibration the mock was priced under, and with the + /// public-only result the offset was measured against. + pub(crate) fn with_calibration( + mut self, + calibration: &crate::propamm::Calibration, + without: PublicOnly, + ) -> Self { + self.offset_bps = Some(calibration.offset_bps); + self.public_best_out = Some(calibration.public_best_out.clone()); + self.without = Some(without); + self + } + + /// An observation for a solve that never produced a quote. + /// + /// Recorded so the sink stays index-aligned with the block's trades — the join back to each + /// comparison record is positional, so a skipped solve would shift every later observation onto + /// the wrong trade. + pub(crate) fn unsolved(token_out: Address) -> Self { + Self { + token_out, + solved: false, + won: false, + committed_amount_out: None, + fee_headroom: None, + offset_bps: None, + public_best_out: None, + without: None, + } + } + + /// The fee the mock could have charged, as a fraction of the committed output, in basis points. + /// + /// This is the headline per-trade number: "the pool could have taken this much fee and the user + /// would still have been better off than on the public market." `None` when the pool didn't win + /// or the commitment is zero. + pub(crate) fn fee_headroom_bps(&self) -> Option { + let committed = biguint_to_f64(self.committed_amount_out.as_ref()?); + let headroom = biguint_to_f64(self.fee_headroom.as_ref()?); + if committed <= 0.0 { + return None; + } + Some(headroom / committed * 10_000.0) + } +} + +/// Running totals over a whole monitor run. +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct Totals { + /// Orders re-solved into a successful quote. + pub solved: u64, + /// Of those, how many routed through the mock `PropAMM` pool. + pub won: u64, + /// Fee headroom valued in USD at top-of-block prices. + pub headroom_usd: f64, + /// Committed output valued in USD — the flow the pool captured. + pub captured_flow_usd: f64, +} + +impl Totals { + /// Share of solved orders the mock pool won, as a percentage. Zero when nothing solved. + // Precision loss is irrelevant: these are trade counts, far below f64's exact-integer range. + #[expect(clippy::cast_precision_loss)] + pub(crate) fn winrate_pct(&self) -> f64 { + if self.solved == 0 { + return 0.0; + } + self.won as f64 / self.solved as f64 * 100.0 + } + + /// Fee headroom as a fraction of captured flow, in basis points — the average fee the pool + /// could have charged across everything it won. Zero when it captured no flow. + pub(crate) fn avg_fee_headroom_bps(&self) -> f64 { + if self.captured_flow_usd <= 0.0 { + return 0.0; + } + self.headroom_usd / self.captured_flow_usd * 10_000.0 + } +} + +/// Shared sink the solve path writes observations into and the block loop drains. +/// +/// `StepAdapter::solve` takes `&self`, so the sink owns its mutability. A `Mutex` is right here: +/// contention is one lock per re-solved order. +#[derive(Debug, Default)] +pub(crate) struct Stats { + pending: Mutex>, + totals: Mutex, + /// The mirrored pair as token symbols, e.g. `WETH/USDC`. Resolved once the feed has loaded the + /// tokens, which is why it is set from the injection path rather than from the CLI. + pair_label: Mutex>, + /// Monotonic counter over calibrated orders, which rotates the offset ladder. Counting only + /// the calibrated ones keeps the groups balanced regardless of how much off-pair flow a + /// block has. + order_index: AtomicU64, +} + +impl Stats { + /// Records one re-solved order. + pub(crate) fn record(&self, observation: Observation) { + if let Ok(mut pending) = self.pending.lock() { + pending.push(observation); + } + } + + /// Takes everything recorded since the last drain. + pub(crate) fn drain(&self) -> Vec { + self.pending + .lock() + .map(|mut pending| std::mem::take(&mut *pending)) + .unwrap_or_default() + } + + /// Folds a block's observations into the run totals and returns the updated snapshot. + pub(crate) fn accumulate( + &self, + observations: &[Observation], + headroom_usd: f64, + captured_flow_usd: f64, + ) -> Totals { + let Ok(mut totals) = self.totals.lock() else { + return Totals::default(); + }; + totals.solved += observations + .iter() + .filter(|o| o.solved) + .count() as u64; + totals.won += observations + .iter() + .filter(|o| o.won) + .count() as u64; + totals.headroom_usd += headroom_usd; + totals.captured_flow_usd += captured_flow_usd; + *totals + } + + /// Hands out the next calibrated order's index, which selects its offset from the ladder. + pub(crate) fn next_order_index(&self) -> u64 { + self.order_index + .fetch_add(1, Ordering::Relaxed) + } + + /// Records the mirrored pair's symbol label, so the report can name it. + pub(crate) fn set_pair_label(&self, label: &str) { + if let Ok(mut pair_label) = self.pair_label.lock() { + if pair_label.is_none() { + *pair_label = Some(label.to_string()); + } + } + } + + /// The mirrored pair's symbol label, once an injection has resolved it. + pub(crate) fn pair_label(&self) -> Option { + self.pair_label + .lock() + .ok() + .and_then(|label| label.clone()) + } + + /// The run totals so far. + pub(crate) fn totals(&self) -> Totals { + self.totals + .lock() + .map(|totals| *totals) + .unwrap_or_default() + } +} + +/// One order scored in both worlds, as the caller computed it against the settled trade. +/// +/// Every field is optional because a world can be unscoreable independently: the order may have +/// been uncalibrated (no "without" pass at all), or its output token unpriced (amounts but no USD). +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct AbResult { + pub without_won: Option, + pub with_won: Option, + pub without_improvement_usd: Option, + pub with_improvement_usd: Option, + pub without_net_bps: Option, + pub with_net_bps: Option, +} + +/// Converts a `BigUint` to `f64` for ratio reporting. Saturates to infinity beyond `f64` range, +/// which the callers guard against by dividing only by positive finite values. +pub(crate) fn biguint_to_f64(value: &BigUint) -> f64 { + value + .to_string() + .parse::() + .unwrap_or(f64::INFINITY) +} + +/// The mock-`PropAMM` fields written into a comparison record, so the offline `report` subcommand +/// reads them alongside everything else it already knows about the trade (venue, solver, pair). +/// +/// Amounts are decimal strings, like every other amount in the record: they exceed `f64`'s exact +/// integer range and JSON has no integer type wide enough. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct Record { + /// The mirrored pair as token symbols, e.g. `WETH/USDC` — which pool the mock stood in for. + pub pair: Option, + /// The offset the mock was priced at, in basis points relative to the public best route. + /// Absent for an order that was not calibrated. + pub offset_bps: Option, + /// Whether the winning route ran through the mock `PropAMM` pool. + pub won: bool, + /// The public-market output the user is committed to. + pub committed_amount_out: Option, + /// Output the mock produced above the commitment — the fee it could have charged. + pub fee_headroom: Option, + /// That headroom as a fraction of the commitment, in basis points. + pub fee_headroom_bps: Option, + /// The committed output valued in USD — the flow the pool captured. + pub committed_usd: Option, + /// The headroom valued in USD. + pub fee_headroom_usd: Option, + /// Whether Fynd beat the settled trade **without** the mock — public liquidity only. + pub without_won: Option, + /// Whether Fynd beat the settled trade **with** the mock available. + pub with_won: Option, + /// USD Fynd gained over the settled trade without the mock. Negative on a loss. + pub without_improvement_usd: Option, + /// USD Fynd gained over the settled trade with the mock available. + pub with_improvement_usd: Option, + /// Net-of-gas bps over the settled trade without the mock. + pub without_net_bps: Option, + /// Net-of-gas bps over the settled trade with the mock available. + pub with_net_bps: Option, +} + +impl Record { + /// Projects an observation into its record, given the USD valuations the caller computed. + pub(crate) fn new( + observed: &Observation, + pair: Option, + committed_usd: Option, + fee_headroom_usd: Option, + ab: AbResult, + ) -> Self { + Self { + pair, + offset_bps: observed.offset_bps, + won: observed.won, + without_won: ab.without_won, + with_won: ab.with_won, + without_improvement_usd: ab.without_improvement_usd, + with_improvement_usd: ab.with_improvement_usd, + without_net_bps: ab.without_net_bps, + with_net_bps: ab.with_net_bps, + committed_amount_out: observed + .committed_amount_out + .as_ref() + .map(std::string::ToString::to_string), + fee_headroom: observed + .fee_headroom + .as_ref() + .map(std::string::ToString::to_string), + fee_headroom_bps: observed.fee_headroom_bps(), + committed_usd, + fee_headroom_usd, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn observation(won: bool, committed: u64, headroom: u64) -> Observation { + Observation { + token_out: Address::from([0x22; 20]), + solved: true, + won, + committed_amount_out: won.then(|| BigUint::from(committed)), + fee_headroom: won.then(|| BigUint::from(headroom)), + offset_bps: None, + public_best_out: None, + without: None, + } + } + + #[test] + fn test_fee_headroom_bps_from_committed_and_headroom() { + let observed = observation(true, 1_000_000, 500); + assert!( + (observed + .fee_headroom_bps() + .expect("a win reports headroom") - + 5.0) + .abs() < + 1e-9 + ); + } + + #[test] + fn test_fee_headroom_bps_absent_when_pool_lost() { + assert!(observation(false, 0, 0) + .fee_headroom_bps() + .is_none()); + } + + #[test] + fn test_fee_headroom_bps_absent_on_zero_commitment() { + // A zero commitment would divide by zero; the ratio is undefined, not infinite. + let mut observed = observation(true, 0, 500); + observed.committed_amount_out = Some(BigUint::ZERO); + assert!(observed.fee_headroom_bps().is_none()); + } + + #[test] + fn test_accumulate_sums_across_blocks() { + let stats = Stats::default(); + stats.accumulate(&[observation(true, 100, 1), observation(false, 0, 0)], 2.0, 400.0); + let totals = stats.accumulate(&[observation(true, 100, 1)], 1.0, 200.0); + + assert_eq!(totals.solved, 3); + assert_eq!(totals.won, 2); + assert!((totals.headroom_usd - 3.0).abs() < 1e-9); + assert!((totals.captured_flow_usd - 600.0).abs() < 1e-9); + assert!((totals.winrate_pct() - 200.0 / 3.0).abs() < 1e-9); + // 3 USD of headroom on 600 USD of captured flow = 50 bps. + assert!((totals.avg_fee_headroom_bps() - 50.0).abs() < 1e-9); + } + + #[test] + fn test_empty_totals_report_zero_rather_than_nan() { + let totals = Totals::default(); + assert!(totals.winrate_pct().abs() < f64::EPSILON); + assert!(totals.avg_fee_headroom_bps().abs() < f64::EPSILON); + } + + #[test] + fn test_drain_empties_the_sink() { + let stats = Stats::default(); + stats.record(observation(true, 100, 1)); + stats.record(observation(false, 0, 0)); + + assert_eq!(stats.drain().len(), 2); + assert!(stats.drain().is_empty(), "a second drain must not repeat observations"); + } + + #[test] + fn test_unsolved_observation_counts_toward_neither_total() { + // A solve that never produced a quote is recorded to keep the sink index-aligned, but it is + // not an order the pool had a chance at — so it must not enter the winrate's denominator. + let stats = Stats::default(); + let totals = stats.accumulate( + &[Observation::unsolved(Address::from([0x22; 20])), observation(true, 100, 1)], + 0.5, + 100.0, + ); + assert_eq!(totals.solved, 1); + assert_eq!(totals.won, 1); + } + + #[test] + fn test_pair_label_is_set_once_and_read_back() { + let stats = Stats::default(); + assert!(stats.pair_label().is_none()); + stats.set_pair_label("WETH/USDC"); + // The mirrored pool can change block to block; the pair it prices cannot, so the first + // label sticks rather than churning. + stats.set_pair_label("DAI/USDC"); + assert_eq!(stats.pair_label().as_deref(), Some("WETH/USDC")); + } +} diff --git a/tools/hindsight/src/report/aggregate.rs b/tools/hindsight/src/report/aggregate.rs index 793c0ba1..833c073e 100644 --- a/tools/hindsight/src/report/aggregate.rs +++ b/tools/hindsight/src/report/aggregate.rs @@ -7,6 +7,13 @@ use std::collections::HashMap; use crate::report::record::Comparison; +/// Slack allowed when checking a group's fee against its offset, in basis points. +/// +/// The calibration divides integer amounts and the source pool rounds its own output, so a fee +/// lands a fraction of a bps off its target. Anything beyond this is a real discrepancy, not +/// rounding. +const FEE_TOLERANCE_BPS: f64 = 0.5; + /// Number of trades listed in the biggest-wins and biggest-losses tables. const TOP_TRADES: usize = 10; /// Number of tokens listed in the unsolvable-tail table. @@ -28,6 +35,211 @@ pub(crate) struct Report { pub top_wins: Vec, pub top_losses: Vec, pub unsolvable_tokens: Vec, + /// Present only for a run the monitor drove with `--propamm-pair`. + pub propamm: Option, +} + +/// The mock-`PropAMM` view: what the pool captured, whether each price group behaved, and the +/// with/without comparison. +pub(crate) struct PropAmm { + /// The mirrored pair as token symbols, e.g. `WETH/USDC`. + pub pair: Option, + /// Committed output on wins, valued in USD — the flow the pool captured. + pub captured_flow_usd: f64, + /// Fee headroom on wins, valued in USD. + pub fee_headroom_usd: f64, + /// Per-offset groups, ascending. Empty for a run with no calibrated orders. + pub groups: Vec, + /// The same orders scored with and without the mock — what the pool actually bought us. + pub uplift: Uplift, +} + +/// The controlled A/B: the same orders, at the same block states, solved with the mock available +/// and with it neutralised. +/// +/// This is the only place the report answers "did the `PropAMM` help", because it is the only +/// comparison where nothing else varies. It covers calibrated orders alone — an off-pair order has +/// no "without" pass, since the mock could never have served it. +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct Uplift { + /// Orders scored in both worlds. + pub orders: usize, + /// Of those, how many Fynd won without the mock. + pub wins_without: usize, + /// Of those, how many Fynd won with the mock. + pub wins_with: usize, + /// USD gained over the settled trades without the mock, summed over winning orders. + pub profit_without_usd: f64, + /// USD gained over the settled trades with the mock, summed over winning orders. + pub profit_with_usd: f64, + /// Median net-of-gas bps over winning orders without the mock. + pub median_bps_without: Option, + /// Median net-of-gas bps over winning orders with the mock. + pub median_bps_with: Option, +} + +impl Uplift { + /// Win rate without the mock, as a percentage. Zero when nothing was scored. + // Precision loss is irrelevant: these are order counts, far below f64's exact-integer range. + #[expect(clippy::cast_precision_loss)] + pub(crate) fn winrate_without_pct(&self) -> f64 { + if self.orders == 0 { + return 0.0; + } + self.wins_without as f64 / self.orders as f64 * 100.0 + } + + /// Win rate with the mock, as a percentage. Zero when nothing was scored. + // Precision loss is irrelevant: these are order counts, far below f64's exact-integer range. + #[expect(clippy::cast_precision_loss)] + pub(crate) fn winrate_with_pct(&self) -> f64 { + if self.orders == 0 { + return 0.0; + } + self.wins_with as f64 / self.orders as f64 * 100.0 + } + + /// Extra orders won because the mock was there. + pub(crate) fn extra_wins(&self) -> i64 { + i64::try_from(self.wins_with).unwrap_or(i64::MAX) - + i64::try_from(self.wins_without).unwrap_or(i64::MAX) + } + + /// Extra USD earned because the mock was there. + pub(crate) fn extra_profit_usd(&self) -> f64 { + self.profit_with_usd - self.profit_without_usd + } +} + +impl PropAmm { + /// The run's overall verdict: the worst of its groups. + /// + /// One failing group fails the run — a violation at any price is a violation, and averaging it + /// against the groups that passed would bury it. + pub(crate) fn verdict(&self) -> GroupVerdict { + if let Some(failure) = self + .groups + .iter() + .find_map(|group| match &group.verdict { + GroupVerdict::Fail(reason) => Some(reason.clone()), + GroupVerdict::Pass | GroupVerdict::NoData => None, + }) + { + return GroupVerdict::Fail(failure); + } + if self + .groups + .iter() + .any(|group| group.verdict == GroupVerdict::Pass) + { + GroupVerdict::Pass + } else { + GroupVerdict::NoData + } + } + + /// Groups that reached a conclusion, over the groups that were run. + pub(crate) fn conclusive(&self) -> (usize, usize) { + let decided = self + .groups + .iter() + .filter(|group| group.verdict != GroupVerdict::NoData) + .count(); + (decided, self.groups.len()) + } +} + +/// Whether an offset group behaved as its price implies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum GroupVerdict { + /// The group met its expectation. + Pass, + /// The group violated it; the string says how. + Fail(String), + /// No calibrated order landed in this group, so there is nothing to conclude. + NoData, +} + +/// One offset group: orders priced the same distance from the public best route, and whether the +/// router treated them the way that price implies. +/// +/// This is the harness's actual test. Each order's competitive situation is constructed, so the +/// group is an assertion rather than a measurement: +/// +/// - **below the market** (`offset_bps < 0`) — the mock is strictly worse, so it must never be +/// selected; +/// - **at the market** (`offset_bps == 0`) — it can only win on gas, and then there is no surplus, +/// so any selection must carry a zero fee; +/// - **above the market** (`offset_bps > 0`) — the fee taken cannot exceed the offset, because the +/// offset is all the surplus there is. +pub(crate) struct PropAmmGroup { + /// The offset these orders were priced at, in basis points off the public best route. + pub offset_bps: i32, + /// Calibrated orders in this group. + pub orders: usize, + /// Of those, how many the router routed through the mock. + pub selected: usize, + /// Largest fee taken in the group, in bps — the number the expectation is checked against. + pub max_fee_bps: Option, + /// Median fee taken, in bps. + pub median_fee_bps: Option, + /// Whether the group met its expectation. + pub verdict: GroupVerdict, +} + +impl PropAmmGroup { + /// What this group is testing, in terms of the pool's price against the best public route. + /// + /// Named by the relationship rather than the raw offset: "worse than the best route" is the + /// thing being asserted, and the basis-point figure is only how it was arranged. + pub(crate) fn title(&self) -> &'static str { + match self.offset_bps.cmp(&0) { + std::cmp::Ordering::Less => "Priced worse than the best route", + std::cmp::Ordering::Equal => "Priced equal to the best route", + std::cmp::Ordering::Greater => "Priced better than the best route", + } + } + + /// The expectation this group's price implies, as one plain sentence. + pub(crate) fn expectation(&self) -> String { + match self.offset_bps.cmp(&0) { + std::cmp::Ordering::Less => "Must never be chosen.".to_string(), + std::cmp::Ordering::Equal => { + "Can only be chosen for its cheaper gas, and then charges no fee.".to_string() + } + std::cmp::Ordering::Greater => format!( + "Should be chosen, and cannot charge more than the {} bps gap.", + self.offset_bps + ), + } + } + + /// What actually happened, as one plain sentence to sit beside the expectation. + pub(crate) fn outcome(&self) -> String { + if self.orders == 0 { + return "No orders landed in this group.".to_string(); + } + if self.selected == 0 { + return format!("Never chosen, across {} orders.", self.orders); + } + match (self.median_fee_bps, self.max_fee_bps) { + (Some(median), Some(max)) => format!( + "Chosen for {} of {} orders, charging {median:.2} bps typically and {max:.2} bps at \ + most.", + self.selected, self.orders + ), + _ => format!("Chosen for {} of {} orders.", self.selected, self.orders), + } + } +} + +impl PropAmm { + /// Fee headroom as a fraction of captured flow, in basis points. `None` when it captured no + /// flow, where the ratio is undefined rather than zero. + pub(crate) fn avg_headroom_bps(&self) -> Option { + (self.captured_flow_usd > 0.0) + .then(|| self.fee_headroom_usd / self.captured_flow_usd * 10_000.0) + } } pub(crate) struct Summary { @@ -93,9 +305,211 @@ pub(crate) fn build(records: &[Comparison]) -> Report { top_wins: top_wins(records), top_losses: top_losses(records), unsolvable_tokens: unsolvable_tokens(records), + propamm: propamm(records), } } +/// The mock-`PropAMM` view, or `None` when no record carries one — i.e. the monitor ran without +/// `--propamm-pair`, so the section is omitted rather than rendered empty. +/// +/// Only records with a `propamm` field count toward `solved`: a run that enabled the harness +/// mid-way would otherwise dilute the winrate with trades the mock never saw. The monitor writes +/// the field for every *solved* order, win or lose, so the field's presence is the denominator. +fn propamm(records: &[Comparison]) -> Option { + let scoped: Vec<&Comparison> = records + .iter() + .filter(|r| r.propamm.is_some()) + .collect(); + if scoped.is_empty() { + return None; + } + let wins: Vec<&Comparison> = scoped + .iter() + .copied() + .filter(|r| { + r.propamm + .as_ref() + .is_some_and(|p| p.won) + }) + .collect(); + Some(PropAmm { + pair: scoped.iter().find_map(|r| { + r.propamm + .as_ref() + .and_then(|p| p.pair.clone()) + }), + captured_flow_usd: sum_propamm(&wins, |p| p.committed_usd), + fee_headroom_usd: sum_propamm(&wins, |p| p.fee_headroom_usd), + groups: propamm_groups(&scoped), + uplift: uplift(&scoped), + }) +} + +/// Sums the with/without A/B over the orders that carry both sides. +/// +/// Restricted to **scored** trades — win or loss — which is the same basis the report's own savings +/// headline uses. A sandwiched trade is excluded there because MEV moved the settled output it is +/// measured against, and including it here would compare a sandwich-aware verdict on one side +/// against a plain amount comparison on the other, so one order can appear to flip when both worlds +/// in fact produced the same result. +/// +/// Wins and profits are counted independently: an order can be scored for its verdict but have an +/// unpriced output token, so it contributes to the win counts and not to the USD. +fn uplift(scoped: &[&Comparison]) -> Uplift { + let mut uplift = Uplift::default(); + let mut bps_without: Vec = Vec::new(); + let mut bps_with: Vec = Vec::new(); + for propamm in scoped + .iter() + .filter(|r| r.top.is_scored()) + .filter_map(|r| r.propamm.as_ref()) + { + let (Some(without_won), Some(with_won)) = (propamm.without_won, propamm.with_won) else { + continue; + }; + uplift.orders += 1; + uplift.wins_without += usize::from(without_won); + uplift.wins_with += usize::from(with_won); + // Only winning orders contribute profit: a loss is not negative revenue, it is a trade Fynd + // would not have served, which is what the win counts already say. + if without_won { + if let Some(usd) = propamm + .without_improvement_usd + .filter(|usd| usd.is_finite()) + { + uplift.profit_without_usd += usd; + } + } + if with_won { + if let Some(usd) = propamm + .with_improvement_usd + .filter(|usd| usd.is_finite()) + { + uplift.profit_with_usd += usd; + } + } + // The bps headline is over wins only, matching the report's own median: how much better + // Fynd was when it won, not diluted by the trades it lost. + if without_won { + bps_without.extend(propamm.without_net_bps); + } + if with_won { + bps_with.extend(propamm.with_net_bps); + } + } + uplift.median_bps_without = median(&mut bps_without); + uplift.median_bps_with = median(&mut bps_with); + uplift +} + +/// Groups the calibrated orders by offset and judges each group against its price. +/// +/// Orders with no offset are excluded: they were not calibrated, so no expectation applies to them. +fn propamm_groups(scoped: &[&Comparison]) -> Vec { + let mut grouped: HashMap> = HashMap::new(); + for record in scoped { + if let Some(offset) = record + .propamm + .as_ref() + .and_then(|p| p.offset_bps) + { + grouped + .entry(offset) + .or_default() + .push(record); + } + } + + let mut groups: Vec = grouped + .into_iter() + .map(|(offset_bps, records)| { + let mut fees: Vec = records + .iter() + .filter(|r| { + r.propamm + .as_ref() + .is_some_and(|p| p.won) + }) + .filter_map(|r| { + r.propamm + .as_ref() + .and_then(|p| p.fee_headroom_bps) + }) + .collect(); + let selected = records + .iter() + .filter(|r| { + r.propamm + .as_ref() + .is_some_and(|p| p.won) + }) + .count(); + let max_fee_bps = fees + .iter() + .copied() + .fold(None::, |acc, fee| Some(acc.map_or(fee, |best| best.max(fee)))); + let mut group = PropAmmGroup { + offset_bps, + orders: records.len(), + selected, + max_fee_bps, + median_fee_bps: median(&mut fees), + verdict: GroupVerdict::NoData, + }; + group.verdict = judge_group(&group); + group + }) + .collect(); + groups.sort_by_key(|group| group.offset_bps); + groups +} + +/// Judges one offset group against the expectation its price implies. +fn judge_group(group: &PropAmmGroup) -> GroupVerdict { + if group.orders == 0 { + return GroupVerdict::NoData; + } + if group.offset_bps < 0 { + return if group.selected == 0 { + GroupVerdict::Pass + } else { + GroupVerdict::Fail(format!( + "priced {} bps below the public market, yet selected for {} of {} orders", + -group.offset_bps, group.selected, group.orders + )) + }; + } + if group.selected == 0 { + // Not a failure at zero offset — the mock only wins there on gas, and it need not be + // cheaper. Above zero it should win, so say so without calling the run broken. + return GroupVerdict::NoData; + } + let Some(max_fee) = group.max_fee_bps else { + return GroupVerdict::NoData; + }; + let ceiling = f64::from(group.offset_bps) + FEE_TOLERANCE_BPS; + if max_fee <= ceiling { + GroupVerdict::Pass + } else { + GroupVerdict::Fail(format!( + "priced {} bps above the public market, yet took a fee of {max_fee:.2} bps", + group.offset_bps + )) + } +} + +/// Sums an optional USD field over records, skipping the ones where the token was not priced. +fn sum_propamm( + records: &[&Comparison], + field: impl Fn(&crate::report::record::PropAmm) -> Option, +) -> f64 { + records + .iter() + .filter_map(|r| r.propamm.as_ref().and_then(&field)) + .filter(|value| value.is_finite()) + .sum() +} + fn summary(records: &[Comparison]) -> Summary { let mut blocks: Vec = records .iter() @@ -387,4 +801,341 @@ mod tests { assert_eq!(median(&mut [4.0, 1.0, 3.0, 2.0]), Some(2.5)); assert_eq!(median(&mut []), None); } + + /// A record carrying a mock-`PropAMM` outcome. + fn propamm_record( + block: u64, + token_in: &str, + token_out: &str, + won: bool, + headroom_bps: f64, + committed_usd: f64, + ) -> Comparison { + serde_json::from_value(serde_json::json!({ + "block": block, + "settled_tx": format!("0x{block:064x}"), + "venue": "relay", + "solver": "1inch", + "token_in": token_in, + "token_out": token_out, + "top": { "verdict": "win", "net_bps": 5.0, "settled_value_usd": 1000.0 }, + "propamm": { + "pair": "WETH/USDC", + "won": won, + "fee_headroom_bps": won.then_some(headroom_bps), + "committed_usd": won.then_some(committed_usd), + "fee_headroom_usd": won.then(|| committed_usd * headroom_bps / 10_000.0), + }, + })) + .unwrap() + } + + #[test] + fn test_propamm_absent_without_the_harness() { + // An ordinary monitor run writes no `propamm` field, so the section must be omitted rather + // than rendered as a run where the pool won nothing. + let records = vec![record(1, "relay", "1inch", "win", Some(5.0))]; + assert!(propamm(&records).is_none()); + } + + #[test] + fn test_propamm_counts_only_records_the_harness_saw() { + // Enabling the harness mid-run must not dilute the winrate with trades the mock never saw. + let records = vec![ + record(1, "relay", "1inch", "win", Some(5.0)), + propamm_record(2, "0xweth", "0xusdc", true, 4.0, 1_000.0), + propamm_record(3, "0xweth", "0xusdc", false, 0.0, 0.0), + ]; + let propamm = propamm(&records).expect("some records carry an outcome"); + assert_eq!(propamm.pair.as_deref(), Some("WETH/USDC")); + // Both records carry an outcome, so both are in scope; only one is a win. + assert!((propamm.captured_flow_usd - 1_000.0).abs() < 1e-6); + } + + #[test] + fn test_propamm_totals_and_flow_weighted_headroom() { + let records = vec![ + propamm_record(1, "0xweth", "0xusdc", true, 4.0, 1_000.0), + propamm_record(2, "0xweth", "0xusdc", true, 8.0, 3_000.0), + propamm_record(3, "0xweth", "0xusdc", false, 0.0, 0.0), + ]; + let propamm = propamm(&records).expect("outcomes present"); + + assert!((propamm.captured_flow_usd - 4_000.0).abs() < 1e-6); + // 1000 @ 4 bps = 0.40, 3000 @ 8 bps = 2.40. + assert!((propamm.fee_headroom_usd - 2.8).abs() < 1e-6); + // Flow-weighted, not the mean of the two bps values: 2.8 / 4000 = 7 bps. + assert!((propamm.avg_headroom_bps().unwrap() - 7.0).abs() < 1e-6); + } + + #[test] + fn test_propamm_avg_headroom_undefined_without_captured_flow() { + // No flow means the ratio has no denominator; reporting 0 would read as "no headroom". + let records = vec![propamm_record(1, "0xweth", "0xusdc", false, 0.0, 0.0)]; + let propamm = propamm(&records).expect("outcomes present"); + assert!(propamm.avg_headroom_bps().is_none()); + } + + /// A calibrated record: priced `offset_bps` off the public best route, selected or not. + fn calibrated(block: u64, offset_bps: i32, won: bool, fee_bps: f64) -> Comparison { + serde_json::from_value(serde_json::json!({ + "block": block, + "settled_tx": format!("0x{block:064x}"), + "venue": "relay", + "solver": "1inch", + "token_in": "0xweth", + "token_out": "0xusdt", + "top": { "verdict": "win", "net_bps": 5.0, "settled_value_usd": 1000.0 }, + "propamm": { + "pair": "WETH/USDT", + "offset_bps": offset_bps, + "won": won, + "fee_headroom_bps": won.then_some(fee_bps), + "committed_usd": won.then_some(1_000.0), + "fee_headroom_usd": won.then(|| fee_bps / 10_000.0 * 1_000.0), + }, + })) + .unwrap() + } + + #[test] + fn test_groups_are_ordered_by_price_ascending() { + let records = vec![ + calibrated(1, 5, true, 5.0), + calibrated(2, -5, false, 0.0), + calibrated(3, 0, true, 0.0), + ]; + let groups = propamm(&records) + .expect("outcomes present") + .groups; + let offsets: Vec = groups + .iter() + .map(|g| g.offset_bps) + .collect(); + assert_eq!(offsets, vec![-5, 0, 5], "cards read below-market to above-market"); + } + + #[test] + fn test_below_market_group_passes_only_when_never_selected() { + let never = vec![calibrated(1, -5, false, 0.0), calibrated(2, -5, false, 0.0)]; + assert_eq!(propamm(&never).unwrap().groups[0].verdict, GroupVerdict::Pass); + + // A single selection below the public market breaks the router's core promise, so the group + // must fail loudly rather than average the violation away. + let once = vec![calibrated(1, -5, false, 0.0), calibrated(2, -5, true, 3.0)]; + let verdict = &propamm(&once).unwrap().groups[0].verdict; + assert!( + matches!(verdict, GroupVerdict::Fail(reason) if reason.contains("below the public")), + "expected a failure naming the violation, got {verdict:?}" + ); + } + + #[test] + fn test_at_market_group_passes_on_a_zero_fee() { + // At the market the mock can only win on gas, and then there is no surplus to take. + let records = vec![calibrated(1, 0, true, 0.0), calibrated(2, 0, false, 0.0)]; + let group = &propamm(&records).unwrap().groups[0]; + assert_eq!(group.verdict, GroupVerdict::Pass); + assert_eq!(group.selected, 1); + } + + #[test] + fn test_at_market_group_fails_on_a_non_zero_fee() { + // Charging a fee at the market price means the user was quoted below the public route. + let records = vec![calibrated(1, 0, true, 4.0)]; + assert!(matches!(&propamm(&records).unwrap().groups[0].verdict, GroupVerdict::Fail(_))); + } + + #[test] + fn test_above_market_group_caps_the_fee_at_the_offset() { + // The offset is all the surplus there is, so a fee within it passes... + let within = vec![calibrated(1, 5, true, 4.8), calibrated(2, 5, true, 5.0)]; + assert_eq!(propamm(&within).unwrap().groups[0].verdict, GroupVerdict::Pass); + + // ...and a fee beyond it means the pool signed more than it could charge. + let beyond = vec![calibrated(1, 5, true, 5.0), calibrated(2, 5, true, 9.0)]; + let verdict = &propamm(&beyond).unwrap().groups[0].verdict; + assert!( + matches!(verdict, GroupVerdict::Fail(reason) if reason.contains("9.00")), + "expected the offending fee in the message, got {verdict:?}" + ); + } + + #[test] + fn test_above_market_fee_tolerance_absorbs_rounding_only() { + // Integer division puts a fee a hair over its target; that is rounding, not a discrepancy. + let rounding = vec![calibrated(1, 5, true, 5.0 + FEE_TOLERANCE_BPS / 2.0)]; + assert_eq!(propamm(&rounding).unwrap().groups[0].verdict, GroupVerdict::Pass); + + let real = vec![calibrated(1, 5, true, 5.0 + FEE_TOLERANCE_BPS * 4.0)]; + assert!(matches!(&propamm(&real).unwrap().groups[0].verdict, GroupVerdict::Fail(_))); + } + + #[test] + fn test_group_with_no_selection_above_market_is_no_data_not_failure() { + // Winning above the market depends on gas as well as price, so never winning is + // inconclusive rather than a violation — the harness must not cry wolf. + let records = vec![calibrated(1, 5, false, 0.0)]; + assert_eq!(propamm(&records).unwrap().groups[0].verdict, GroupVerdict::NoData); + } + + #[test] + fn test_uncalibrated_orders_form_no_group() { + // Off-pair orders carry no offset, so no expectation applies and they must not dilute a + // group. + let records = vec![ + propamm_record(1, "0xdai", "0xusdc", false, 0.0, 0.0), + calibrated(2, 5, true, 5.0), + ]; + let propamm = propamm(&records).expect("outcomes present"); + assert_eq!(propamm.groups.len(), 1); + assert_eq!(propamm.groups[0].orders, 1); + } + + #[test] + fn test_empty_group_says_so_rather_than_claiming_it_was_never_chosen() { + // "Never chosen" and "no orders" are different findings; the card must not conflate them. + let group = PropAmmGroup { + offset_bps: 0, + orders: 0, + selected: 0, + max_fee_bps: None, + median_fee_bps: None, + verdict: GroupVerdict::NoData, + }; + assert!(group.outcome().contains("No orders")); + } + + #[test] + fn test_each_group_states_its_rule_in_terms_of_the_best_route() { + // The card's job is to say what is being tested without the reader translating basis + // points. + let group = |offset_bps: i32| PropAmmGroup { + offset_bps, + orders: 4, + selected: 0, + max_fee_bps: None, + median_fee_bps: None, + verdict: GroupVerdict::Pass, + }; + assert_eq!(group(-5).title(), "Priced worse than the best route"); + assert_eq!(group(0).title(), "Priced equal to the best route"); + assert_eq!(group(5).title(), "Priced better than the best route"); + assert!(group(-5) + .expectation() + .contains("never be chosen")); + assert!(group(0) + .expectation() + .contains("no fee")); + assert!(group(5) + .expectation() + .contains("5 bps gap")); + assert!(group(-5) + .outcome() + .contains("Never chosen, across 4 orders")); + } + + /// A calibrated record carrying the with/without A/B. + fn ab_record( + block: u64, + without_won: bool, + with_won: bool, + without_usd: f64, + with_usd: f64, + ) -> Comparison { + serde_json::from_value(serde_json::json!({ + "block": block, + "settled_tx": format!("0x{block:064x}"), + "venue": "relay", "solver": "1inch", + "token_in": "0xeth", "token_out": "0xusdc", + "top": { "verdict": "win", "net_bps": 5.0, "settled_value_usd": 1000.0 }, + "propamm": { + "pair": "ETH/USDC", "offset_bps": 5, "won": with_won && !without_won, + "without_won": without_won, "with_won": with_won, + "without_improvement_usd": without_usd, "with_improvement_usd": with_usd, + }, + })) + .unwrap() + } + + #[test] + fn test_uplift_counts_wins_in_both_worlds() { + let records = vec![ + ab_record(1, false, true, -2.0, 3.0), // the mock turned a loss into a win + ab_record(2, true, true, 4.0, 6.0), // already won; the mock made it worth more + ab_record(3, false, false, -1.0, -1.0), // the mock could not help + ]; + let uplift = propamm(&records).unwrap().uplift; + + assert_eq!(uplift.orders, 3); + assert_eq!(uplift.wins_without, 1); + assert_eq!(uplift.wins_with, 2); + assert_eq!(uplift.extra_wins(), 1); + } + + #[test] + fn test_uplift_profit_counts_winning_orders_only() { + // A loss is not negative revenue — it is a trade Fynd would not have served, which the win + // counts already express. Summing losing orders' USD would understate both columns. + let records = + vec![ab_record(1, false, true, -2.0, 3.0), ab_record(2, true, true, 4.0, 6.0)]; + let uplift = propamm(&records).unwrap().uplift; + + assert!( + (uplift.profit_without_usd - 4.0).abs() < 1e-9, + "the lost order contributes nothing" + ); + assert!((uplift.profit_with_usd - 9.0).abs() < 1e-9); + assert!((uplift.extra_profit_usd() - 5.0).abs() < 1e-9); + } + + #[test] + fn test_uplift_winrates_are_over_the_same_orders() { + let records = vec![ + ab_record(1, false, true, 0.0, 1.0), + ab_record(2, false, true, 0.0, 1.0), + ab_record(3, false, false, 0.0, 0.0), + ab_record(4, false, false, 0.0, 0.0), + ]; + let uplift = propamm(&records).unwrap().uplift; + assert!(uplift.winrate_without_pct().abs() < f64::EPSILON); + assert!((uplift.winrate_with_pct() - 50.0).abs() < 1e-9); + } + + #[test] + fn test_uplift_skips_orders_scored_in_only_one_world() { + // An off-pair order has no "without" pass; including it would inflate the denominator with + // orders the mock could never have served. + let records = vec![ + propamm_record(1, "0xdai", "0xusdc", false, 0.0, 0.0), + ab_record(2, false, true, 0.0, 2.0), + ]; + let uplift = propamm(&records).unwrap().uplift; + assert_eq!(uplift.orders, 1); + } + + #[test] + fn test_uplift_reports_an_absolute_gain_over_a_zero_baseline() { + // The public side earning nothing is not a divide-by-zero problem; the delta still stands. + let records = vec![ab_record(1, false, true, 0.0, 5.0)]; + let uplift = propamm(&records).unwrap().uplift; + assert!((uplift.extra_profit_usd() - 5.0).abs() < 1e-9); + } + + #[test] + fn test_uplift_reports_a_regression_as_negative() { + // If the mock ever made things worse, the numbers must say so rather than clamp at zero. + let records = vec![ab_record(1, true, false, 5.0, 0.0)]; + let uplift = propamm(&records).unwrap().uplift; + assert_eq!(uplift.extra_wins(), -1); + assert!(uplift.extra_profit_usd() < 0.0); + } + + #[test] + fn test_empty_uplift_reports_zero_rather_than_nan() { + let uplift = Uplift::default(); + assert!(uplift.winrate_without_pct().abs() < f64::EPSILON); + assert!(uplift.winrate_with_pct().abs() < f64::EPSILON); + assert_eq!(uplift.extra_wins(), 0); + } } diff --git a/tools/hindsight/src/report/html.rs b/tools/hindsight/src/report/html.rs index ec69abc8..c264c76a 100644 --- a/tools/hindsight/src/report/html.rs +++ b/tools/hindsight/src/report/html.rs @@ -9,7 +9,8 @@ use std::fmt::Write as _; use crate::report::aggregate::{ - Count, GroupStats, Report, Savings, Summary, TradeRow, VerdictStat, + Count, GroupStats, GroupVerdict, PropAmm, PropAmmGroup, Report, Savings, Summary, TradeRow, + VerdictStat, }; /// Shortest share of a stacked column that gets an inline `12.3%` label. Below it the segment is @@ -51,7 +52,12 @@ fn verdict_name(verdict: &str) -> String { /// the report says which slice of trades it covers. pub(crate) fn render(report: &Report, filter: Option<&str>) -> String { let mut html = String::from(HEAD); - html.push_str(&hero_section(&report.savings, &report.summary, filter)); + // A calibrated run's headline is the with/without split, so it replaces the single-world hero + // rather than sitting beside a number that silently mixes the two. + match report.propamm.as_ref() { + Some(propamm) => html.push_str(&propamm_hero(propamm, &report.summary, filter)), + None => html.push_str(&hero_section(&report.savings, &report.summary, filter)), + } html.push_str(&verdict_section(&report.verdicts)); html.push_str(&trades_section("Top savings", &report.top_wins)); html.push_str(&trades_section("Biggest losses", &report.top_losses)); @@ -268,6 +274,208 @@ fn token_section(tokens: &[Count]) -> String { section("Unsolved token tail", &table) } +/// The headline for a calibrated run: the same orders scored with and without the mock, side by +/// side, plus one verdict per price group. +/// +/// Two columns of the same three figures the single-world hero shows, so the comparison is read by +/// scanning across rather than by reading a caption. Everything is a number: the deltas carry the +/// sign, colour carries the direction, and each figure is labelled. +fn propamm_hero(propamm: &PropAmm, summary: &Summary, filter: Option<&str>) -> String { + let uplift = &propamm.uplift; + let scope = filter.map_or_else( + || "all venues".to_string(), + |venue| format!("venue: {}", escape(venue)), + ); + let pair = propamm + .pair + .as_deref() + .unwrap_or("unknown pair"); + let (verdict, verdict_cls) = match propamm.verdict() { + GroupVerdict::Pass => ("PASS", "pos"), + GroupVerdict::Fail(_) => ("FAIL", "neg"), + GroupVerdict::NoData => ("NO DATA", "idlenum"), + }; + let mini = |value: &str, label: &str| { + format!( + "
{value}
\ +
{label}
" + ) + }; + format!( + "
\ +
hindsight report {scope}\ + mock PropAMM {verdict}\ + {}
\ + {}\ +
{}{}
\ +
{}
\ +
{}{}{}{}{}
\ +
", + escape(pair), + propamm_tests(propamm), + world_column( + "without PropAMM", + "", + uplift.profit_without_usd, + uplift.winrate_without_pct(), + uplift.median_bps_without, + None + ), + world_column( + "with PropAMM", + "on", + uplift.profit_with_usd, + uplift.winrate_with_pct(), + uplift.median_bps_with, + Some(( + uplift.extra_profit_usd(), + uplift.winrate_with_pct() - uplift.winrate_without_pct() + )) + ), + lp_capture(propamm), + mini(&fmt_count(summary.distinct_blocks), "blocks"), + mini(&fmt_count(uplift.orders), "orders scored both ways"), + mini(&format!("{:+}", uplift.extra_wins()), "extra orders won"), + mini(&fmt_count(summary.total), "comparisons"), + mini(&fmt_usd(propamm.captured_flow_usd), "flow through the pool"), + ) +} + +/// The three price tests: one banner verdict, then one card per test. +/// +/// Each card leads with what it is testing — the pool's price against the best public route — then +/// states the rule and what happened, so a reader never has to translate a basis-point offset into +/// an expectation themselves. +fn propamm_tests(propamm: &PropAmm) -> String { + let (decided, total) = propamm.conclusive(); + let (banner, cls) = match propamm.verdict() { + GroupVerdict::Pass => ("TESTS PASSED", "pos"), + GroupVerdict::Fail(_) => ("TESTS FAILED", "neg"), + GroupVerdict::NoData => ("NO CONCLUSION YET", "idlenum"), + }; + format!( + "
\ +
{banner}
\ +
{decided} of {total} price tests conclusive
\ +
{}", + propamm_groups(&propamm.groups), + ) +} + +/// One world's three headline figures, with the deltas attached to the second column. +fn world_column( + title: &str, + modifier: &str, + profit_usd: f64, + winrate_pct: f64, + median_bps: Option, + deltas: Option<(f64, f64)>, +) -> String { + let (profit_delta, winrate_delta) = deltas.map_or_else( + || (String::new(), String::new()), + |(profit, winrate)| { + ( + signed_delta(profit, &fmt_usd_signed(profit)), + signed_delta(winrate, &format!("{winrate:+.1} pts")), + ) + }, + ); + let big = |value: &str, label: &str, delta: &str| { + format!( + "
{value}
\ +
{label} {delta}
" + ) + }; + format!( + "
{}
{}{}{}
", + escape(title), + big(&fmt_usd(profit_usd), "Fynd savings (wins uplift)", &profit_delta), + big(&format!("{winrate_pct:.1}%"), "win rate", &winrate_delta), + big(&fmt_bps_signed(median_bps), "median savings bps (wins)", ""), + ) +} + +/// The fee the pool captured for its LPs — where the underbid lands, since the taker's quote is +/// pinned to the public reference. +fn lp_capture(propamm: &PropAmm) -> String { + format!( + "
+{}
\ +
captured for LPs {}
", + fmt_usd(propamm.fee_headroom_usd), + signed_delta(1.0, &fmt_bps(propamm.avg_headroom_bps())), + ) +} + +/// A signed figure coloured by direction. Colour is never the only carrier — the sign is in the +/// text. +fn signed_delta(value: f64, formatted: &str) -> String { + let cls = match value + .partial_cmp(&0.0) + .unwrap_or(std::cmp::Ordering::Equal) + { + std::cmp::Ordering::Greater => "pos", + std::cmp::Ordering::Less => "neg", + std::cmp::Ordering::Equal => "idlenum", + }; + format!("{}", escape(formatted)) +} + +/// A USD amount with an explicit sign, for a delta. +fn fmt_usd_signed(value: f64) -> String { + format!("{}{}", if value < 0.0 { "-" } else { "+" }, fmt_usd(value.abs())) +} + +/// One card per price test, ascending by price. +/// +/// The verdict word is the largest thing on the card, because that is the answer. Under it the rule +/// and the observation sit as two plain sentences, so "pass" is always accompanied by what passed. +fn propamm_groups(groups: &[PropAmmGroup]) -> String { + if groups.is_empty() { + return "

No calibrated orders yet — no settled trade so far was on the \ + mirrored pair.

" + .to_string(); + } + let mut cards = String::new(); + for (index, group) in groups.iter().enumerate() { + let (word, cls) = match &group.verdict { + GroupVerdict::Pass => ("PASS", "pos"), + GroupVerdict::Fail(_) => ("FAIL", "neg"), + GroupVerdict::NoData => ("NO DATA", "idlenum"), + }; + let detail = match &group.verdict { + GroupVerdict::Fail(reason) => { + format!("

{}

", escape(reason)) + } + GroupVerdict::Pass | GroupVerdict::NoData => String::new(), + }; + let _ = write!( + cards, + "
\ +
{}. {}
\ +
{}
\ +
{word}
\ +
{}
\ +
{}
{detail}\ +
", + index + 1, + escape(group.title()), + escape(&fmt_offset(group.offset_bps)), + escape(&group.expectation()), + escape(&group.outcome()), + ); + } + format!("
{cards}
") +} + +/// An offset as a signed bps label, so a column header reads as a price and not a bare number. +fn fmt_offset(offset_bps: i32) -> String { + match offset_bps.cmp(&0) { + std::cmp::Ordering::Less => format!("set {} bps below it", -offset_bps), + std::cmp::Ordering::Equal => "set exactly on it".to_string(), + std::cmp::Ordering::Greater => format!("set {offset_bps} bps above it"), + } +} + fn section(title: &str, body: &str) -> String { format!("

{}

{body}
", escape(title)) } @@ -400,6 +608,52 @@ section { background: #211a30; border: 1px solid #362b4a; border-radius: 8px; .collab { color: #9a8bbf; font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; text-align: center; } .nodata { color: #9a8bbf; margin: 0; } +/* A section's framing sentence: what the numbers below mean, before they are read. */ +.note { color: #b9adcf; margin: 0 0 .5rem; max-width: 68ch; line-height: 1.55; } +/* The run's verdict, sized like the report's headline savings figure. */ +.idlenum { color: #9a8bbf; } +/* The two worlds side by side, so the comparison is read by scanning across. */ +.worlds { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-top: 1.5rem; } +.world { flex: 1 1 20rem; border: 1px solid #2f2540; border-radius: 8px; padding: 1.1rem 1.3rem; } +/* The "with" column is the answer, so it is the one that is lit. */ +.world.on { border-color: #43a047; background: #16241a; } +.worldtitle { color: #9a8bbf; font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; + margin-bottom: .75rem; } +.world .herostat { margin-bottom: .9rem; } +.world .herostat:last-child { margin-bottom: 0; } +.lprow { margin-top: 1.5rem; } +/* One card per offset group. They wrap rather than scroll, so a wider ladder stays readable. */ +.groups { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1.5rem 0; } +.group { flex: 1 1 15rem; border: 1px solid #2f2540; border-radius: 6px; padding: .9rem 1rem; } +.grouphead { font-weight: 600; font-size: .95rem; } +.groupoff { color: #9a8bbf; font-size: .78rem; margin-top: .15rem; } +.grouprule { color: #b9adcf; font-size: .82rem; line-height: 1.45; } +.groupsaw { color: #e9e2f5; font-size: .82rem; line-height: 1.45; margin-top: .35rem; font-weight: 600; } +/* A card's border echoes its verdict, but the verdict word above it is what carries the meaning. */ +.group.poscard { border-color: #43a047; background: #16241a; } +.group.negcard { border-color: #e53935; background: #241616; } +/* The tests banner: the single answer, above the three cards that justify it. */ +.testsbanner { margin-top: 1.75rem; } +/* The number the group's expectation is about, big enough to read across a room. */ +.groupbig { font-size: 2.6rem; font-weight: 700; line-height: 1.05; margin-top: .5rem; } +.groupcap { color: #9a8bbf; font-size: .78rem; } +.groupfee { color: #b9adcf; font-size: .82rem; margin-top: .6rem; } +.groupexp { color: #6f6390; font-size: .72rem; margin-top: .5rem; } +/* The verdict never rests on colour alone: each chip is also labelled pass / FAIL / no data. */ +.chip.poschip { background: #1b3a1f; color: #a5d6a7; } +.chip.negchip { background: #4a1c1c; color: #ef9a9a; } +.chip.idlenumchip { background: #2f2540; color: #9a8bbf; } +.groupfail { color: #ef9a9a; font-size: .78rem; margin: .6rem 0 0; line-height: 1.45; } +/* Each sub-test carries its own verdict word, sized so the three read at a glance together. */ +.groupverdict { font-size: 2.4rem; font-weight: 800; letter-spacing: .04em; margin: .5rem 0 .6rem; } +/* The with/without table: the "with" column is the answer, so it carries the weight. */ +.ab { margin: 1.5rem 0 .25rem; } +.ab th { color: #9a8bbf; font-weight: 500; } +.ab td.strong { font-weight: 700; font-size: 1.05rem; } +.delta { font-weight: 600; } +.abnote { color: #6f6390; font-size: .76rem; margin: 0 0 .5rem; max-width: 74ch; line-height: 1.5; } +/* Where the underbid actually lands, set apart from the taker-side rows above it. */ +.ab tr.abfee td { border-top: 1px solid #2f2540; padding-top: .55rem; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #362b4a; } th { color: #9a8bbf; font-weight: 600; font-size: .8rem; text-transform: uppercase; } @@ -553,4 +807,158 @@ mod tests { fn test_escape_replaces_markup() { assert_eq!(escape("&\"x\""), "<a>&"x""); } + /// A report from calibrated records across the three offset groups, with the A/B attached. + fn group_report(above_fee_bps: f64) -> Report { + let record = |block: u64, offset: i32, won: bool, fee: f64| { + serde_json::json!({ + "block": block, + "settled_tx": format!("0x{block:064x}"), + "venue": "relay", "solver": "1inch", + "token_in": "0x0000000000000000000000000000000000000000", + "token_out": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "top": {"verdict": "win", "net_bps": 5.0, "improvement_usd": 4.0, + "settled_value_usd": 1000.0}, + "propamm": { + "pair": "ETH/USDC", "offset_bps": offset, "won": won, + "fee_headroom_bps": won.then_some(fee), + "committed_usd": won.then_some(1_000.0), + "fee_headroom_usd": won.then_some(fee / 10.0), + "without_won": true, "with_won": true, + "without_improvement_usd": 4.0, "with_improvement_usd": 4.0, + "without_net_bps": 5.0, "with_net_bps": 5.0, + }, + }) + }; + let records: Vec = vec![ + record(1, -5, false, 0.0), + record(2, 0, true, 0.0), + record(3, 5, true, above_fee_bps), + ] + .into_iter() + .map(|v| serde_json::from_value(v).unwrap()) + .collect(); + build(&records) + } + + /// A calibrated run whose records carry no offset, so no group reaches a conclusion. + fn uncalibrated_report() -> Report { + let records: Vec = vec![serde_json::json!({ + "block": 1, + "settled_tx": "0xabc0000000000000000000000000000000000000000000000000000000000001", + "venue": "relay", "solver": "1inch", + "token_in": "0xaaa", "token_out": "0xbbb", + "top": {"verdict": "win", "net_bps": 20.0, "improvement_usd": 12.0, + "settled_value_usd": 1000.0}, + "propamm": {"pair": "ETH/USDC", "won": false}, + })] + .into_iter() + .map(|v| serde_json::from_value(v).unwrap()) + .collect(); + build(&records) + } + + #[test] + fn test_ordinary_run_keeps_the_single_world_hero() { + // A run without the harness must look exactly as it did before it existed. + let html = render(&sample_report(), None); + assert!(html.contains("Fynd savings")); + assert!(!html.contains("without PropAMM")); + assert!(!html.contains("mock PropAMM")); + } + + #[test] + fn test_calibrated_run_splits_the_hero_into_both_worlds() { + // The point of the redesign: the headline figures appear twice, once per world, rather than + // one number that silently mixes them. + let html = render(&group_report(5.0), None); + assert!(html.contains("without PropAMM")); + assert!(html.contains("with PropAMM")); + assert_eq!( + html.matches("Fynd savings (wins uplift)") + .count(), + 2, + "the savings figure is stated for each world" + ); + assert_eq!( + html.matches("herolab\">win rate") + .count(), + 2 + ); + assert_eq!( + html.matches("median savings bps (wins)") + .count(), + 2 + ); + } + + #[test] + fn test_split_hero_replaces_rather_than_joins_the_single_world_one() { + // Two heroes would mean two different win rates on one page, one of them ambiguous. + let html = render(&group_report(5.0), None); + assert_eq!(html.matches("class=\"hero\"").count(), 1); + } + + #[test] + fn test_hero_names_the_pair_and_the_overall_verdict() { + let html = render(&group_report(5.0), None); + assert!(html.contains("ETH/USDC"), "the pair is named by symbol, not by address"); + assert!(html.contains("mock PropAMM PASS")); + assert!(render(&group_report(20.0), None).contains("mock PropAMM FAIL")); + } + + #[test] + fn test_hero_shows_where_the_underbid_lands() { + // The taker-side figures barely move by design, so the LP capture has to be on the page or + // the run reads as a null result. + let html = render(&group_report(5.0), None); + assert!(html.contains("captured for LPs")); + } + + #[test] + fn test_each_group_carries_its_own_verdict_word() { + // Three sub-tests, three verdicts — the overall PASS must not be the only one visible. + let html = render(&group_report(5.0), None); + let verdicts = html + .matches("class=\"groupverdict") + .count(); + assert_eq!(verdicts, 3, "one verdict per offset group"); + } + + #[test] + fn test_group_cards_name_each_test_in_terms_of_the_best_route() { + // A reader should not have to turn a basis-point offset into an expectation themselves. + let html = render(&group_report(5.0), None); + assert!(html.contains("Priced worse than the best route")); + assert!(html.contains("Priced equal to the best route")); + assert!(html.contains("Priced better than the best route")); + assert!(html.contains("Must never be chosen.")); + assert!(html.contains("charges no fee")); + assert!(html.contains("cannot charge more than the 5 bps gap")); + // And each card says what actually happened next to what should have. + assert!(html.contains("Never chosen, across")); + } + + #[test] + fn test_tests_banner_states_one_answer_for_the_three_cards() { + assert!(render(&group_report(5.0), None).contains("TESTS PASSED")); + assert!(render(&group_report(20.0), None).contains("TESTS FAILED")); + assert!(render(&group_report(5.0), None).contains("price tests conclusive")); + } + + #[test] + fn test_a_failing_group_renders_the_reason_not_just_a_colour() { + let html = render(&group_report(20.0), None); + assert!(html.contains(">FAIL<")); + assert!( + html.contains("took a fee of 20.00 bps"), + "the reason must be readable without opening the JSONL" + ); + } + + #[test] + fn test_group_panel_says_so_when_nothing_was_calibrated() { + // A run whose settled trades never touched the mirrored pair must explain the empty panel + // rather than render three blank cards. + assert!(render(&uncalibrated_report(), None).contains("No calibrated orders")); + } } diff --git a/tools/hindsight/src/report/record.rs b/tools/hindsight/src/report/record.rs index 93104803..4d46047a 100644 --- a/tools/hindsight/src/report/record.rs +++ b/tools/hindsight/src/report/record.rs @@ -19,6 +19,55 @@ pub(crate) struct Comparison { pub token_out: String, /// Optimistic state (N-1); the report's headline, matching the monitor's headline verdict. pub top: State, + /// The mock-`PropAMM` outcome, present only for runs the monitor drove with `--propamm-pair`. + #[serde(default)] + pub propamm: Option, +} + +/// One trade's mock-`PropAMM` outcome, as written by `monitor --propamm-pair`. +/// +/// The mock pool quotes at a configured fee-free price and charges nothing, so `fee_headroom` is +/// the fee the signed extension could have charged on this trade and still beaten the public +/// market. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct PropAmm { + /// The mirrored pair as token symbols, e.g. `WETH/USDC`. + #[serde(default)] + pub pair: Option, + /// The offset the mock was priced at, in basis points relative to the public best route for + /// this order. Absent for an order that was not calibrated, which is excluded from the + /// group test. + #[serde(default)] + pub offset_bps: Option, + /// Whether the winning route ran through the mock pool. + pub won: bool, + /// That headroom as a fraction of the committed output, in basis points. + #[serde(default)] + pub fee_headroom_bps: Option, + /// The committed output valued in USD — the flow the pool captured. + #[serde(default)] + pub committed_usd: Option, + /// The headroom valued in USD. + #[serde(default)] + pub fee_headroom_usd: Option, + /// Whether Fynd beat the settled trade **without** the mock — public liquidity only. + #[serde(default)] + pub without_won: Option, + /// Whether Fynd beat the settled trade **with** the mock available. + #[serde(default)] + pub with_won: Option, + /// USD Fynd gained over the settled trade without the mock. Negative on a loss. + #[serde(default)] + pub without_improvement_usd: Option, + /// USD Fynd gained over the settled trade with the mock available. + #[serde(default)] + pub with_improvement_usd: Option, + /// Net-of-gas bps over the settled trade without the mock. + #[serde(default)] + pub without_net_bps: Option, + /// Net-of-gas bps over the settled trade with the mock available. + #[serde(default)] + pub with_net_bps: Option, } /// Fynd's result at one block state. @@ -106,7 +155,7 @@ mod tests { let range = build_range(&trade, &prices, top, Outcome::Unsolvable("x".into())); let mut buf = Vec::new(); - write_comparisons(&mut buf, std::slice::from_ref(&range), &prices, &prices); + write_comparisons(&mut buf, std::slice::from_ref(&range), &prices, &prices, &[]); let line = String::from_utf8(buf).unwrap(); let record: Comparison = serde_json::from_str(line.trim()).unwrap(); @@ -119,4 +168,80 @@ mod tests { assert!((record.top.improvement_usd.unwrap() - 10.0).abs() < 1e-3); assert_eq!(record.token_out, format!("{usdc:#x}")); } + + /// The mock-`PropAMM` fields the monitor writes parse back too — the same writer/reader drift + /// guard, for the fields the `PropAMM` section keys off. + #[test] + fn test_parses_the_propamm_fields_from_the_monitor_writer() { + use crate::propamm::report::{Observation, Record}; + + let usdc: Address = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + .parse() + .unwrap(); + let observed = Observation { + token_out: usdc, + solved: true, + won: true, + committed_amount_out: Some(1_000_000_000u64.into()), + fee_headroom: Some(400_000u64.into()), + offset_bps: Some(5), + public_best_out: Some(1_000_000_000u64.into()), + without: Some(crate::propamm::report::PublicOnly { + amount_out: 1_000_000_000u64.into(), + amount_out_net_gas: 999_000_000u64.into(), + }), + }; + let record = Record::new( + &observed, + Some("WETH/USDC".to_string()), + Some(1_000.0), + Some(0.4), + crate::propamm::report::AbResult { + without_won: Some(false), + with_won: Some(true), + without_improvement_usd: Some(-1.0), + with_improvement_usd: Some(3.0), + without_net_bps: Some(-8.0), + with_net_bps: Some(24.0), + }, + ); + + let line = serde_json::to_string(&serde_json::json!({ + "block": 1, + "settled_tx": "0xabc", + "venue": "relay", + "solver": "1inch", + "token_in": "0xaaa", + "token_out": "0xbbb", + "top": { "verdict": "win" }, + "propamm": record, + })) + .unwrap(); + + let parsed: Comparison = serde_json::from_str(&line).unwrap(); + let propamm = parsed + .propamm + .expect("the propamm field round-trips"); + assert_eq!(propamm.pair.as_deref(), Some("WETH/USDC")); + assert_eq!(propamm.offset_bps, Some(5)); + assert!(propamm.won); + // 400_000 / 1_000_000_000 = 4 bps. + assert!((propamm.fee_headroom_bps.unwrap() - 4.0).abs() < 1e-9); + assert!((propamm.committed_usd.unwrap() - 1_000.0).abs() < 1e-9); + assert!((propamm.fee_headroom_usd.unwrap() - 0.4).abs() < 1e-9); + } + + /// An ordinary monitor run writes no `propamm` field, and the reader must treat that as absent + /// rather than failing to parse the whole record. + #[test] + fn test_propamm_is_absent_when_the_harness_is_off() { + let line = serde_json::json!({ + "block": 1, "settled_tx": "0xabc", "venue": "relay", "solver": "1inch", + "token_in": "0xaaa", "token_out": "0xbbb", "top": { "verdict": "win" }, + "propamm": serde_json::Value::Null, + }) + .to_string(); + let parsed: Comparison = serde_json::from_str(&line).unwrap(); + assert!(parsed.propamm.is_none()); + } } diff --git a/tools/hindsight/src/resolve/jsonl.rs b/tools/hindsight/src/resolve/jsonl.rs index f5d40c7a..af0d46be 100644 --- a/tools/hindsight/src/resolve/jsonl.rs +++ b/tools/hindsight/src/resolve/jsonl.rs @@ -118,14 +118,21 @@ fn date_from_unix(secs: u64) -> String { /// filter to wins for the improvement view or to unsolvables for the coverage worklist (where Fynd /// needs to improve). Losses keep their route (what path Fynd took and lost on); unsolvables keep /// the reason. +/// `propamm` carries one optional mock-`PropAMM` outcome per range, in the same order; a shorter +/// slice simply leaves the trailing records without a `propamm` field. pub(crate) fn write_comparisons( writer: &mut W, ranges: &[RangeComparison], prices_top: &Prices, prices_back: &Prices, + propamm: &[Option], ) { - for range in ranges { - let Ok(line) = serde_json::to_string(&comparison_record(range, prices_top, prices_back)) + for (index, range) in ranges.iter().enumerate() { + let propamm = propamm + .get(index) + .and_then(Option::as_ref); + let Ok(line) = + serde_json::to_string(&comparison_record(range, prices_top, prices_back, propamm)) else { continue; }; @@ -146,6 +153,7 @@ fn comparison_record( range: &RangeComparison, prices_top: &Prices, prices_back: &Prices, + propamm: Option<&crate::propamm::report::Record>, ) -> serde_json::Value { serde_json::json!({ "block": range.block_number, @@ -167,6 +175,9 @@ fn comparison_record( "sandwich": range.sandwich, "top": state_record(&range.top, range, prices_top), "back": state_record(&range.back, range, prices_back), + // Absent unless the run had `--propamm-pair` set, so an ordinary run's records are + // byte-identical to before. + "propamm": propamm, }) } @@ -338,7 +349,7 @@ mod tests { Outcome::Unsolvable("x".into()), Outcome::Unsolvable("x".into()), ); - let rec = comparison_record(&range, &empty_prices(), &empty_prices()); + let rec = comparison_record(&range, &empty_prices(), &empty_prices(), None); assert_eq!(rec.pointer("/tx_index").unwrap(), 3); assert_eq!( rec.pointer("/quoted_amount_out") @@ -428,7 +439,7 @@ mod tests { }); let range = build_range(&trade, &prices, top, back); - let rec = comparison_record(&range, &prices, &prices); + let rec = comparison_record(&range, &prices, &prices, None); let top_usd = rec .pointer("/top/improvement_usd") .unwrap() @@ -494,7 +505,7 @@ mod tests { Outcome::Unsolvable("missing token in Tycho".into()), Outcome::Unsolvable("missing token in Tycho".into()), ); - let rec = comparison_record(&range, &empty_prices(), &empty_prices()); + let rec = comparison_record(&range, &empty_prices(), &empty_prices(), None); assert_eq!(rec.pointer("/top/verdict").unwrap(), "unsolvable"); assert_eq!( rec.pointer("/top/unsolvable_reason") @@ -545,7 +556,7 @@ mod tests { }) }; let range = build_range(&trade, &empty_prices(), solved(1_100), solved(1_050)); - let rec = comparison_record(&range, &empty_prices(), &empty_prices()); + let rec = comparison_record(&range, &empty_prices(), &empty_prices(), None); assert_eq!(rec.pointer("/tx_index").unwrap(), 42); assert_eq!(rec.pointer("/top/verdict").unwrap(), "sandwiched"); diff --git a/tools/hindsight/src/resolve/monitor.rs b/tools/hindsight/src/resolve/monitor.rs index b9ef2b92..fea3f5f6 100644 --- a/tools/hindsight/src/resolve/monitor.rs +++ b/tools/hindsight/src/resolve/monitor.rs @@ -10,6 +10,7 @@ use std::{ future::Future, pin::Pin, + sync::Arc, time::{Duration, Instant}, }; @@ -23,7 +24,7 @@ use fynd_core::{ parse_chain, EncodingOptions, Order, OrderQuote, OrderSide, QuoteOptions, QuoteRequest, QuoteStatus, }, - BlockStepController, FyndBuilder, Solver, + BlockStepController, FyndBuilder, LiquidityScope, Solver, }; use num_bigint::BigUint; use tracing::{debug, info, warn}; @@ -31,8 +32,8 @@ 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}, + propamm, provider_from, + resolve::{resolve_block_range, Outcome, RangeComparison, SolvedAmount, SteppingSolver}, telemetry, usd::Prices, }; @@ -116,6 +117,69 @@ pub(crate) struct MonitorArgs { /// reason; filter downstream for the improvement or coverage view #[arg(long)] pub comparisons_dir: Option, + + /// Mirror a mock `PropAMM` pool onto this token pair, as two comma-separated addresses + /// (`--propamm-pair 0xWETH,0xUSDC`). The mock carries the best real pool's live curve at the + /// price set by `--propamm-price-pct` and charges no fee. It is hidden from the public worker + /// pools and visible to a parallel exclusive-access twin of each configured pool, so each + /// re-solved order reports whether the `PropAMM` route won and how much fee it could have + /// charged on top and still won. Off when omitted, and never usable against a real chain — the + /// mock has no pool behind it + #[arg(long, env = "PROPAMM_PAIR", value_delimiter = ',')] + pub propamm_pair: Option>, + + /// Price offsets, in basis points relative to **the public best route's output for the order + /// being solved**. Orders on the mirrored pair cycle through this list, so one run fills every + /// group and each order becomes an assertion rather than a data point: + /// + /// - a negative offset prices the mock below the public market and must never be selected; + /// - `0` matches it exactly, so any selection is on gas alone and must carry a zero fee; + /// - a positive offset is the underbid, and the fee taken must not exceed it. + /// + /// The mock's fee-free price is set per order, so "5 bps better" means 5 bps better than the + /// route Fynd would otherwise have quoted — not 5 bps better than some single pool + // `allow_hyphen_values` so a negative offset reads as a value, not as a short flag. + #[arg( + long, + env = "PROPAMM_OFFSETS_BPS", + value_delimiter = ',', + allow_hyphen_values = true, + default_values_t = [-5, 0, 5] + )] + pub propamm_offsets_bps: Vec, + + /// Re-solve only the settled trades whose own pair is the mirrored one — the only orders the + /// harness can calibrate. Off-pair trades are the large majority, so skipping them fills the + /// offset groups far faster per block. The trade-off: that run's comparisons cover the + /// mirrored pair alone, so its savings and coverage numbers are not a picture of Fynd + /// overall + #[arg(long, env = "PROPAMM_ONLY_PAIR", default_value_t = false)] + pub propamm_only_pair: bool, + + /// Trade size, in whole units of the pair's first token, used each block to pick which real + /// pool to mirror. Set it near the sizes being re-solved so the mirror tracks the pool that + /// actually prices those trades best + #[arg(long, env = "PROPAMM_PROBE_UNITS", default_value_t = 1.0)] + pub propamm_probe_units: f64, +} + +/// The mock-`PropAMM` scaffold attached to one monitor run. +/// +/// `stats` is created once per run so the totals span solver rebuilds; `injector` is rebuilt with +/// each solver, because it publishes on that solver's market-event channel. +struct PropAmmHarness { + injector: tokio::sync::Mutex, + stats: Arc, + /// The pair and offset ladder, kept outside the injector's mutex so the solve path can decide + /// whether an order is calibratable without taking that lock. + config: propamm::MirrorConfig, +} + +impl PropAmmHarness { + /// The mirrored pair and offset ladder. + fn config(&self) -> &propamm::MirrorConfig { + &self.config + } } /// Drives the in-process solver, stepping the chain one block per `SteppingSolver::advance`. @@ -123,6 +187,8 @@ struct StepAdapter<'a> { solver: &'a Solver, controller: &'a BlockStepController, timeout_ms: u64, + /// Present when `--propamm-pair` is set; drives mock-pool injection and collects its outcomes. + propamm: Option<&'a PropAmmHarness>, } impl StepAdapter<'_> { @@ -135,51 +201,20 @@ impl StepAdapter<'_> { .last_updated() .map(fynd_core::BlockInfo::number) } -} - -#[async_trait] -impl SteppingSolver for StepAdapter<'_> { - async fn solve(&self, token_in: Address, token_out: Address, amount_in: U256) -> Outcome { - let Ok(amount) = amount_in.to_string().parse::() else { - return Outcome::Unsolvable("unparseable amount_in".to_string()); - }; - // Placeholder receiver: routing/amounts are receiver-independent; it only fills the encoded - // calldata's recipient. Encoding is requested so each quote carries its on-chain - // transaction (note: this refines gas estimates and a failed encode yields - // Unsolvable). - let order = Order::new( - CoreAddress::from(token_in.into_array()), - CoreAddress::from(token_out.into_array()), - amount, - OrderSide::Sell, - CoreAddress::from([0x11u8; 20]), - ); - let request = QuoteRequest::new( - vec![order], - QuoteOptions::default() - .with_timeout_ms(self.timeout_ms) - .with_encoding_options(EncodingOptions::new(0.005)), - ); - - match self.solver.quote(request).await { - Ok(quote) => quote.orders().first().map_or_else( - || Outcome::Unsolvable("solver returned no order quote".to_string()), - order_quote_to_outcome, - ), - Err(e) => Outcome::Unsolvable(format!("solve error: {e}")), - } - } - async fn advance(&self) -> anyhow::Result<()> { + /// Releases the next block and waits until the solver applies it. + /// + /// An error means the feed died — either its stream ended (peek returns None once the gating + /// task exits) or it jammed without ending (no block within `FEED_DEAD_TIMEOUT`). The caller + /// rebuilds the solver on any error. + async fn step_block(&self) -> anyhow::Result<()> { let before = self.current_block().await; self.controller .trigger_next_block() .map_err(|_| anyhow::anyhow!("tycho stream ended (trigger channel closed)"))?; // Deterministic barrier: wait until the solver applies a block strictly newer than - // `before`. An error here means the feed died — either its stream ended (peek returns - // None once the gating task exits) or it jammed without ending (no block within - // FEED_DEAD_TIMEOUT). The caller rebuilds the solver on any error. + // `before`. let stall_started = Instant::now(); let mut next_warn = stall_started + STALL_WARN_INTERVAL; loop { @@ -212,6 +247,222 @@ impl SteppingSolver for StepAdapter<'_> { } } } + + /// Re-mirrors the mock `PropAMM` pool onto the freshly applied block's state. + /// + /// A failure here leaves the mock holding the previous block's state, which skews its quotes + /// but does not invalidate the block's public comparison — so it warns and continues rather + /// than ending the session. + async fn mirror_propamm_pool(&self) { + let Some(harness) = self.propamm else { + return; + }; + let Some(block) = self.current_block().await else { + return; + }; + match harness + .injector + .lock() + .await + .inject(self.solver, block) + .await + { + Ok(Some(injected)) => { + harness + .stats + .set_pair_label(&injected.pair_label); + debug!( + block, + source = injected.source_component, + source_price = injected.source_price, + derived_data_ready = injected.derived_data_ready, + "mirrored the mock PropAMM pool" + ); + } + Ok(None) => warn!( + block, + "no source pool for the mirrored pair carries state yet; the mock PropAMM is \ + inactive this block" + ), + Err(e) => warn!(block, "failed to mirror the mock PropAMM pool: {e}"), + } + } +} + +impl StepAdapter<'_> { + /// Quote one sell order at the solver's current state, returning the outcome and — when a quote + /// came back at all — its mock-`PropAMM` observation. + async fn quote_order( + &self, + token_in: Address, + token_out: Address, + amount_in: U256, + ) -> (Outcome, Option) { + let Ok(amount) = amount_in.to_string().parse::() else { + return (Outcome::Unsolvable("unparseable amount_in".to_string()), None); + }; + // Placeholder receiver: routing/amounts are receiver-independent; it only fills the encoded + // calldata's recipient. Encoding is requested so each quote carries its on-chain + // transaction (note: this refines gas estimates and a failed encode yields + // Unsolvable). + let order = Order::new( + CoreAddress::from(token_in.into_array()), + CoreAddress::from(token_out.into_array()), + amount, + OrderSide::Sell, + CoreAddress::from([0x11u8; 20]), + ); + let request = QuoteRequest::new( + vec![order], + QuoteOptions::default() + .with_timeout_ms(self.timeout_ms) + .with_encoding_options(EncodingOptions::new(0.005)), + ); + + match self.solver.quote(request).await { + Ok(quote) => { + let Some(order_quote) = quote.orders().first() else { + return ( + Outcome::Unsolvable("solver returned no order quote".to_string()), + None, + ); + }; + let observed = propamm::report::Observation::from_quote(order_quote, token_out); + (order_quote_to_outcome(order_quote), Some(observed)) + } + Err(e) => (Outcome::Unsolvable(format!("solve error: {e}")), None), + } + } +} + +impl StepAdapter<'_> { + /// Solve an order twice so the mock is priced against what Fynd would otherwise have quoted. + /// + /// Pass one neutralises the mock, so the quote is the public best route. Pass two rescales the + /// mock to land `offset_bps` off that output and re-quotes, and its result is the one returned + /// — the order's real answer, with a known-by-construction competitive situation. + /// + /// Falls back to the pass-one result whenever calibration is impossible (no source cached yet, + /// or the source cannot price the order). The order then carries no offset and is reported + /// as an ordinary comparison rather than as a group member. + async fn solve_calibrated( + &self, + harness: &PropAmmHarness, + token_in: Address, + token_out: Address, + amount_in: U256, + offset_bps: i32, + ) -> (Outcome, Option) { + let market = harness_market(self.solver); + let injector = harness.injector.lock().await; + + if !injector.neutralize(&market).await { + return self + .quote_order(token_in, token_out, amount_in) + .await; + } + let (public_outcome, public_observed) = self + .quote_order(token_in, token_out, amount_in) + .await; + let Outcome::Solved(public) = &public_outcome else { + // Fynd cannot serve this order publicly, so there is no reference to underbid. Report + // the unsolvable outcome as-is rather than inventing a price for the mock + // to beat. + return (public_outcome, public_observed); + }; + let Ok(public_best_out) = public + .amount_out + .to_string() + .parse::() + else { + return (public_outcome, public_observed); + }; + + let core_token_in = CoreAddress::from(token_in.into_array()); + let Some(calibration) = injector + .calibrate( + &market, + &core_token_in, + &u256_to_biguint(amount_in), + &public_best_out, + offset_bps, + ) + .await + else { + return (public_outcome, public_observed); + }; + drop(injector); + + // The calibration's own error check: integer division and the source pool's rounding mean + // the target lands a fraction of a bps off the requested offset. A large gap means + // the source could not hold the target, which would make the group's expectation + // untestable. + let realized_bps = calibration.realized_offset_bps(); + if (realized_bps - f64::from(offset_bps)).abs() > 0.5 { + warn!( + offset_bps, + realized_bps, + "mock PropAMM calibration missed its target offset; this order's group result is \ + not trustworthy" + ); + } + debug!( + offset_bps, + realized_bps, "calibrated the mock PropAMM against the public best route" + ); + + let (outcome, observed) = self + .quote_order(token_in, token_out, amount_in) + .await; + let without = propamm::report::PublicOnly { + amount_out: public_best_out.clone(), + amount_out_net_gas: u256_to_biguint(public.amount_out_net_gas), + }; + let observed = observed.map(|o| o.with_calibration(&calibration, without)); + (outcome, observed) + } +} + +#[async_trait] +impl SteppingSolver for StepAdapter<'_> { + async fn solve(&self, token_in: Address, token_out: Address, amount_in: U256) -> Outcome { + let calibrated_offset = self.propamm.and_then(|harness| { + let config = harness.config(); + let core_in = CoreAddress::from(token_in.into_array()); + let core_out = CoreAddress::from(token_out.into_array()); + config + .serves(&core_in, &core_out) + .then(|| config.offset_for(harness.stats.next_order_index())) + .flatten() + }); + + let (outcome, observed) = match (self.propamm, calibrated_offset) { + (Some(harness), Some(offset_bps)) => { + self.solve_calibrated(harness, token_in, token_out, amount_in, offset_bps) + .await + } + _ => { + self.quote_order(token_in, token_out, amount_in) + .await + } + }; + // Recorded on every path, including the ones that never produced a quote: the sink is + // joined back to the block's trades by position, so a skipped solve would shift + // every later observation onto the wrong trade. `Observation::solved` is what the + // winrate counts. + if let Some(harness) = self.propamm { + harness.stats.record( + observed.unwrap_or_else(|| propamm::report::Observation::unsolved(token_out)), + ); + } + outcome + } + + async fn advance(&self) -> anyhow::Result<()> { + self.step_block().await?; + self.mirror_propamm_pool().await; + Ok(()) + } } fn order_quote_to_outcome(quote: &OrderQuote) -> Outcome { @@ -332,6 +583,23 @@ async fn build_solver( .add_pool(name, pool) .map_err(|e| anyhow::anyhow!("failed to add worker pool {name}: {e}"))?; } + if cfg.propamm_pair.is_some() { + // Twin every configured pool with an exclusive-access copy: same algorithm and hop limits, + // so the two scopes differ only in whether they can see the mock pool. Anything less would + // confound the PropAMM's advantage with an algorithm difference. The unscoped originals + // stay public — `FyndBuilder` hands the exclusivity policy to every pool that does not + // opt into `LiquidityScope::All`. + builder = builder.exclusivity_policy(propamm::is_mock_component); + for (name, pool) in pools_config.pools() { + let twin = pool + .clone() + .with_liquidity_scope(LiquidityScope::All); + let twin_name = format!("{name}__propamm"); + builder = builder + .add_pool(&twin_name, &twin) + .map_err(|e| anyhow::anyhow!("failed to add worker pool {twin_name}: {e}"))?; + } + } builder .build_with_step_controller() .await @@ -429,23 +697,7 @@ pub(crate) async fn run(cfg: MonitorArgs) -> anyhow::Result<()> { .await .map_err(|e| anyhow::anyhow!("failed to resolve protocols: {e}"))?; - // Load worker pools like `fynd serve`: the default path falls back to the built-in default - // pools when absent; custom paths that don't exist fail fast. - let default_path = std::path::Path::new("worker_pools.toml"); - let pools_config = - if cfg.worker_pools_config.as_path() == default_path && !default_path.exists() { - info!("worker_pools.toml not found; using Fynd's built-in default pools"); - fynd_rpc::config::WorkerPoolsConfig::builtin_default() - } else { - fynd_rpc::config::WorkerPoolsConfig::load_from_file(&cfg.worker_pools_config).map_err( - |e| { - anyhow::anyhow!( - "failed to load worker pools config {}: {e}", - cfg.worker_pools_config.display() - ) - }, - )? - }; + let pools_config = load_pools_config(&cfg.worker_pools_config)?; let mut decoder = Decoder::new( provider_from(&cfg.chain.rpc_url)?, @@ -466,6 +718,10 @@ pub(crate) async fn run(cfg: MonitorArgs) -> anyhow::Result<()> { None => None, }; + // Parsed before the first (multi-minute) solver build, so a typo in the pair fails immediately. + let propamm_config = propamm_config(&cfg, chain)?; + let propamm_stats = Arc::new(propamm::report::Stats::default()); + let mut totals = Totals::default(); let pacing = Pacing::for_chain(chain, cfg.max_lag_blocks); info!( @@ -488,8 +744,21 @@ pub(crate) async fn run(cfg: MonitorArgs) -> anyhow::Result<()> { built = build_solver(&cfg, chain, &protocols, &pools_config) => built?, }; loop { - let adapter = - StepAdapter { solver: &solver, controller: &controller, timeout_ms: cfg.timeout_ms }; + // Rebuilt with each solver: the injector publishes on that solver's market-event channel. + // `propamm_stats` is shared, so the totals span rebuilds. + let propamm = propamm_config + .clone() + .map(|config| PropAmmHarness { + injector: tokio::sync::Mutex::new(propamm::Injector::new(&solver, config.clone())), + stats: Arc::clone(&propamm_stats), + config, + }); + let adapter = StepAdapter { + solver: &solver, + controller: &controller, + timeout_ms: cfg.timeout_ms, + propamm: propamm.as_ref(), + }; let reason = tokio::select! { biased; () = &mut shutdown => { @@ -521,9 +790,311 @@ pub(crate) async fn run(cfg: MonitorArgs) -> anyhow::Result<()> { (solver, controller) = built; } solver.shutdown(); + if let Some(config) = propamm_config.as_ref() { + log_propamm_summary(config, &propamm_stats.totals()); + } Ok(()) } +/// Loads the worker pools like `fynd serve` does. +/// +/// The default path falls back to the built-in default pools when absent; a custom path that does +/// not exist fails fast, because an operator who named a file meant that file. +fn load_pools_config( + path: &std::path::Path, +) -> anyhow::Result { + let default_path = std::path::Path::new("worker_pools.toml"); + if path == default_path && !default_path.exists() { + info!("worker_pools.toml not found; using Fynd's built-in default pools"); + return Ok(fynd_rpc::config::WorkerPoolsConfig::builtin_default()); + } + fynd_rpc::config::WorkerPoolsConfig::load_from_file(path) + .map_err(|e| anyhow::anyhow!("failed to load worker pools config {}: {e}", path.display())) +} + +/// Builds the mock-`PropAMM` config from the CLI, or `None` when `--propamm-pair` is absent. +fn propamm_config( + cfg: &MonitorArgs, + chain: Chain, +) -> anyhow::Result> { + let Some(pair) = cfg.propamm_pair.as_deref() else { + return Ok(None); + }; + let (token_a, token_b) = propamm::MirrorConfig::parse_pair(pair)?; + let config = propamm::MirrorConfig { + token_a, + token_b, + offsets_bps: cfg.propamm_offsets_bps.clone(), + probe_units: cfg.propamm_probe_units, + chain, + }; + info!( + token_a = %config.token_a, + token_b = %config.token_b, + offsets_bps = ?config.offsets_bps, + probe_units = config.probe_units, + "mock PropAMM enabled; its quotes are not executable" + ); + Ok(Some(config)) +} + +/// Logs the whole run's mock-`PropAMM` result: the assumption that went in, and the winrate and fee +/// headroom that came out. +fn log_propamm_summary(config: &propamm::MirrorConfig, totals: &propamm::report::Totals) { + info!( + offsets_bps = ?config.offsets_bps, + solved_orders = totals.solved, + propamm_wins = totals.won, + winrate_pct = format!("{:.1}", totals.winrate_pct()), + captured_flow_usd = format!("{:.0}", totals.captured_flow_usd), + fee_headroom_usd = format!("{:.2}", totals.headroom_usd), + fee_headroom_bps = format!("{:.2}", totals.avg_fee_headroom_bps()), + "mock PropAMM run summary" + ); +} + +/// Values one block's mock-`PropAMM` observations in USD, folds them into the run totals, logs the +/// running picture, and returns one record per trade for the comparisons JSONL. +/// +/// Amounts are valued at top-of-block prices, matching the headline improvement metric. Both halves +/// are valued: the committed output answers "how much flow the pool captured", the headroom answers +/// "how much fee it could have charged on that flow", and their ratio is that fee in bps. +/// +/// `resolve_block_range` solves every trade at top-of-block in order, advances, then solves every +/// trade again at back-of-block in the same order — so the sink holds `2 * trade_count` +/// observations and the first half are the top-of-block ones the report keys off. A different count +/// means that pairing no longer holds, so the records are dropped rather than misattributed; the +/// totals are unaffected either way. +fn report_propamm_block( + harness: &PropAmmHarness, + block: u64, + prices: &Prices, + observations: &[propamm::report::Observation], + ranges: &[RangeComparison], +) -> Vec> { + let trade_count = ranges.len(); + let mut headroom_usd = 0.0; + let mut captured_flow_usd = 0.0; + let mut records: Vec> = Vec::with_capacity(observations.len()); + let pair_label = harness.stats.pair_label(); + + for observed in observations { + // A record is written for every solved order, not only the wins: the report needs the + // losses as the winrate's denominator, and a wins-only file makes every run look + // like 100%. Unsolved solves stay `None` — the pool never had a chance at those. + if !observed.solved { + records.push(None); + continue; + } + let value_usd = |amount: Option<&BigUint>| { + amount + .and_then(biguint_to_u256_opt) + .and_then(|amount| prices.value_usd(observed.token_out, amount)) + }; + let headroom = value_usd(observed.fee_headroom.as_ref()); + let committed = value_usd(observed.committed_amount_out.as_ref()); + headroom_usd += headroom.unwrap_or(0.0); + captured_flow_usd += committed.unwrap_or(0.0); + // The A/B is only defined for calibrated orders, and only against the range this + // observation pairs with — the first `trade_count` observations are the + // top-of-block ones. + let ab = ranges + .get(records.len()) + .map_or_else(propamm::report::AbResult::default, |range| { + score_both_worlds(observed, range, prices) + }); + records.push(Some(propamm::report::Record::new( + observed, + pair_label.clone(), + committed, + headroom, + ab, + ))); + } + + let totals = harness + .stats + .accumulate(observations, headroom_usd, captured_flow_usd); + let block_wins = observations + .iter() + .filter(|o| o.won) + .count(); + info!( + block, + block_solves = observations.len(), + block_wins, + block_fee_headroom_usd = format!("{headroom_usd:.2}"), + run_winrate_pct = format!("{:.1}", totals.winrate_pct()), + run_fee_headroom_usd = format!("{:.2}", totals.headroom_usd), + run_fee_headroom_bps = format!("{:.2}", totals.avg_fee_headroom_bps()), + "mock PropAMM block result" + ); + + if records.len() == trade_count * 2 { + records.truncate(trade_count); + return records; + } + if !observations.is_empty() { + warn!( + block, + solves = observations.len(), + trades = trade_count, + "PropAMM observations do not pair with the block's trades; omitting them from the \ + comparisons JSONL" + ); + } + vec![None; trade_count] +} + +/// Narrows a block's trades to the mirrored pair when `--propamm-only-pair` is set. +/// +/// The count is logged rather than dropped quietly: a run that silently skipped most of a block +/// would read as a block with little flow. +fn scope_trades( + trades: Vec, + adapter: &StepAdapter<'_>, + only_pair: bool, + block: u64, +) -> Vec { + let Some(harness) = adapter.propamm.filter(|_| only_pair) else { + return trades; + }; + let total = trades.len(); + let kept = retain_mirrored_pair(trades, harness.config()); + debug!( + block, + kept = kept.len(), + skipped = total - kept.len(), + "--propamm-only-pair: re-solving the mirrored pair's trades only" + ); + kept +} + +/// Scores one order in both worlds against what actually settled. +/// +/// "Without" is the pass the calibration already ran with the mock neutralised; "with" is the +/// range's own top-of-block result. Both are compared to the settled trade the same way the +/// headline verdict is — output net of gas — so the pair is directly comparable and the difference +/// is the mock's entire contribution. +fn score_both_worlds( + observed: &propamm::report::Observation, + range: &RangeComparison, + prices: &Prices, +) -> propamm::report::AbResult { + let Some(without) = observed.without.as_ref() else { + return propamm::report::AbResult::default(); + }; + let settled_net_gas = u256_to_biguint(range.settled_amount_out_net_gas); + let with = match &range.top.outcome { + Outcome::Solved(solved) => Some(solved), + Outcome::Partial(_) | Outcome::Unsolvable(_) => None, + }; + let improvement = |amount_out: &BigUint| { + biguint_to_u256_opt(amount_out) + .and_then(|out| prices.savings_usd(range.token_out, out, range.settled_amount_out)) + }; + // A win is gross output strictly above the settled gross output — the basis `compare::verdict` + // uses, so these figures drop straight into the report's headline. The settled route's gas is + // often unattributable, which makes gross-vs-gross the only always-like-for-like comparison. + let settled_out = u256_to_biguint(range.settled_amount_out); + let with_won = range.top.verdict == super::compare::Verdict::Win; + propamm::report::AbResult { + without_won: Some(without.amount_out > settled_out), + with_won: with.map(|_| with_won), + without_improvement_usd: improvement(&without.amount_out), + with_improvement_usd: with.and_then(|s| improvement(&u256_to_biguint(s.amount_out))), + // Net of each side's own gas, matching the headline's median savings figure. + without_net_bps: fynd_tools_common::bps::raw_bps_diff( + &without.amount_out_net_gas, + &settled_net_gas, + ), + with_net_bps: range.top.deltas.net_bps, + } +} + +/// Keeps only the trades whose own token pair is the mirrored one, in either direction. +fn retain_mirrored_pair( + trades: Vec, + config: &propamm::MirrorConfig, +) -> Vec { + trades + .into_iter() + .filter(|trade| { + config.serves( + &CoreAddress::from(trade.token_in.into_array()), + &CoreAddress::from(trade.token_out.into_array()), + ) + }) + .collect() +} + +/// The solver's market-data handle, which the injector writes the mock's state through. +fn harness_market(solver: &Solver) -> fynd_core::feed::market_data::MarketData { + solver.market_data() +} + +/// Converts a `U256` amount to `BigUint`, which is what the mock's calibration math uses. +fn u256_to_biguint(amount: U256) -> BigUint { + BigUint::from_bytes_be(&amount.to_be_bytes::<32>()) +} + +/// Converts a `BigUint` to `U256`, or `None` when it does not fit — the amount is then left +/// unvalued rather than silently reported as zero. +fn biguint_to_u256_opt(value: &BigUint) -> Option { + let bytes = value.to_bytes_be(); + if bytes.len() > 32 { + return None; + } + let mut buf = [0u8; 32]; + buf[32 - bytes.len()..].copy_from_slice(&bytes); + Some(U256::from_be_bytes(buf)) +} + +/// Record one block's re-solved trades: Prometheus metrics, the mock-`PropAMM` roll-up, and the +/// comparisons JSONL. +/// +/// The `PropAMM` outcomes are drained before the comparisons are written, so each range carries its +/// outcome into the same JSONL record the offline `report` subcommand reads. They are drained +/// unconditionally when the harness is on, so a block whose trades all failed to solve still clears +/// the sink rather than carrying observations into the next block. +#[expect(clippy::too_many_arguments)] +fn emit_block_results( + cfg: &MonitorArgs, + adapter: &StepAdapter<'_>, + decoder: &Decoder

, + comparisons: &mut Option, + block: u64, + ranges: &[RangeComparison], + prices_top: &Prices, + prices_back: &Prices, +) { + for range in ranges { + telemetry::record_range( + range, + &cfg.chain.name, + prices_top, + prices_back, + decoder.registry(), + ); + } + let propamm_records = match adapter.propamm { + Some(harness) => { + let observations = harness.stats.drain(); + report_propamm_block(harness, block, prices_top, &observations, ranges) + } + None => vec![None; ranges.len()], + }; + if let Some(rotating) = comparisons.as_mut() { + super::jsonl::write_comparisons( + rotating.writer(), + ranges, + prices_top, + prices_back, + &propamm_records, + ); + } +} + /// Drive one solver session: step blocks and re-solve each block's settled trades until the run /// completes or the feed dies. async fn run_session( @@ -594,6 +1165,8 @@ async fn run_session( } }; + let trades = scope_trades(trades, adapter, cfg.propamm_only_pair, target); + let start = Instant::now(); // Snapshot token prices at top-of-block (N-1) for the headline metric and the top-of-block // USD valuation. @@ -617,18 +1190,16 @@ async fn run_session( "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); - } + emit_block_results( + cfg, + adapter, + decoder, + comparisons, + target, + &ranges, + &prices_top, + &prices_back, + ); let elapsed_s = start.elapsed().as_secs_f64(); telemetry::record_block_seconds(elapsed_s); @@ -776,8 +1347,56 @@ mod tests { max_blocks: Some(1), max_lag_blocks: Some(100), comparisons_dir: None, + propamm_pair: None, + propamm_offsets_bps: vec![-5, 0, 5], + propamm_only_pair: false, + propamm_probe_units: 1.0, }) .await .expect("monitor should process one block without error"); } + + #[test] + fn test_retain_mirrored_pair_keeps_both_directions_only() { + let eth = Address::ZERO; + let usdc = Address::repeat_byte(0xaa); + let dai = Address::repeat_byte(0xbb); + let config = propamm::MirrorConfig { + token_a: CoreAddress::from(eth.into_array()), + token_b: CoreAddress::from(usdc.into_array()), + offsets_bps: vec![-5, 0, 5], + probe_units: 1.0, + chain: Chain::Ethereum, + }; + let trade = |token_in: Address, token_out: Address| DecodedTrade { + tx_hash: alloy::primitives::TxHash::default(), + block_number: 1, + tx_index: 0, + venue: "relay".into(), + solver: "tycho".into(), + solver_source: crate::decoder::AttributionSource::TraceMatch, + decoder: "sender-netting", + sender: Address::ZERO, + token_in, + token_out, + amount_in: U256::from(1u64), + amount_out: U256::from(1u64), + venue_fee_in: None, + venue_fee_out: None, + settled_gas: None, + quote: None, + sandwich: None, + }; + + let kept = retain_mirrored_pair( + vec![ + trade(eth, usdc), // mirrored, forward + trade(usdc, eth), // mirrored, reverse + trade(dai, usdc), // off-pair + trade(eth, dai), // shares one token only + ], + &config, + ); + assert_eq!(kept.len(), 2, "only the mirrored pair survives, in either direction"); + } }