Skip to main content

fynd_core/
replay.rs

1//! Re-execute an already-built [`Route`] against a (possibly newer)
2//! [`MarketState`](crate::feed::market_data::MarketState).
3//!
4//! A route emitted by a solving algorithm pins pools, token order, and split fractions. Replaying
5//! it against a later block's pool states answers "what would this exact route have produced at
6//! that state" — the in-process equivalent of submitting the already-encoded transaction at that
7//! block. Used by tooling (e.g. `hindsight`) to measure slippage between quote time and
8//! execution time.
9
10use std::collections::HashMap;
11
12use num_bigint::BigUint;
13use tycho_simulation::tycho_common::{models::Address, simulation::protocol_sim::ProtocolSim};
14
15use crate::{
16    algorithm::{sim_guard::GuardedProtocolSim, split_primitives::split_amount},
17    feed::market_data::MarketState,
18    types::{ComponentId, Route, Swap},
19};
20
21/// The outcome of replaying a route: final output and summed per-swap gas.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RouteReplay {
24    /// Amount of the route's output token produced.
25    pub amount_out: BigUint,
26    /// Sum of the per-swap gas estimates reported by the simulations.
27    pub gas: BigUint,
28}
29
30/// Why a route could not be replayed against a market state.
31#[derive(Debug, thiserror::Error)]
32pub enum ReplayError {
33    /// The route carries no swaps (only reachable via deserialization — [`Route::new`] rejects
34    /// an empty swap list).
35    #[error("route has no swaps")]
36    EmptyRoute,
37    /// The market state carries no simulation state for a pool in the route (removed by the feed
38    /// or filtered out since the route was built).
39    #[error("no simulation state for component {0}")]
40    MissingState(ComponentId),
41    /// A token in the route is missing from the market state's token registry.
42    #[error("token {0} missing from the market state")]
43    MissingToken(Address),
44    /// A pool simulation failed (e.g. the pool was paused or its liquidity vanished).
45    #[error("simulation failed on component {component_id}: {error}")]
46    Simulation {
47        /// The pool whose simulation failed.
48        component_id: ComponentId,
49        /// The underlying simulation error.
50        error: String,
51    },
52}
53
54/// Replay `route` against `market`, producing the output its swaps yield at that state.
55///
56/// Swaps execute in the route's emitted order, which is topological: every swap producing a token
57/// runs before any swap consuming it. Split fractions are interpreted exactly as routes encode
58/// them — within the swaps consuming one token, each positive `split` takes that fraction of the
59/// token's collected balance, and the final swap of the group (split `0.0`) takes the remainder.
60/// Post-swap pool states are threaded, so a pool shared by two swaps sees depleted reserves on
61/// the second. Each swap's embedded quote-time `protocol_state` is deliberately ignored: pools
62/// and tokens are resolved from `market`, the state being replayed against.
63///
64/// # Errors
65///
66/// Returns [`ReplayError`] when a pool's simulation state or a token is missing from `market`,
67/// or a pool simulation fails.
68pub fn replay_route(route: &Route, market: &MarketState) -> Result<RouteReplay, ReplayError> {
69    let swaps = route.swaps();
70    let (Some(input_token), Some(output_token)) = (route.input_token(), route.output_token())
71    else {
72        return Err(ReplayError::EmptyRoute);
73    };
74    let total_in: BigUint = swaps
75        .iter()
76        .filter(|swap| *swap.token_in() == input_token)
77        .map(Swap::amount_in)
78        .sum();
79
80    // Collected balance per token. A token's balance is complete before its first consuming swap
81    // runs (topological order), and `branch_totals` snapshots it at that moment: positive splits
82    // are fractions of that total, not of the running remainder.
83    let mut available: HashMap<Address, BigUint> = HashMap::new();
84    available.insert(input_token, total_in);
85    let mut branch_totals: HashMap<Address, BigUint> = HashMap::new();
86    let mut post_swap: HashMap<ComponentId, Box<dyn ProtocolSim>> = HashMap::new();
87    let mut total_gas = BigUint::ZERO;
88
89    for swap in swaps {
90        let token_in = market
91            .get_token(swap.token_in())
92            .ok_or_else(|| ReplayError::MissingToken(swap.token_in().clone()))?;
93        let token_out = market
94            .get_token(swap.token_out())
95            .ok_or_else(|| ReplayError::MissingToken(swap.token_out().clone()))?;
96
97        let branch_total = branch_totals
98            .entry(swap.token_in().clone())
99            .or_insert_with(|| {
100                available
101                    .get(swap.token_in())
102                    .cloned()
103                    .unwrap_or_default()
104            })
105            .clone();
106        let remaining = available
107            .entry(swap.token_in().clone())
108            .or_default();
109        let amount_in = if *swap.split() > 0.0 {
110            // Flooring in split_amount keeps the sum of parts at or under the total, but cap at
111            // the remainder anyway so a malformed split can never underflow the balance.
112            let (part, _) = split_amount(&branch_total, *swap.split());
113            part.min(remaining.clone())
114        } else {
115            remaining.clone()
116        };
117        *remaining -= &amount_in;
118
119        let sim = post_swap
120            .get(swap.component_id())
121            .map(|state| state.as_ref())
122            .or_else(|| market.get_simulation_state(swap.component_id()))
123            .ok_or_else(|| ReplayError::MissingState(swap.component_id().to_string()))?;
124        let result = sim
125            .get_amount_out_guarded(amount_in, token_in, token_out)
126            .map_err(|e| ReplayError::Simulation {
127                component_id: swap.component_id().to_string(),
128                error: e.to_string(),
129            })?;
130
131        total_gas += &result.gas;
132        *available
133            .entry(swap.token_out().clone())
134            .or_default() += &result.amount;
135        post_swap.insert(swap.component_id().to_string(), result.new_state);
136    }
137
138    let amount_out = available
139        .remove(&output_token)
140        .unwrap_or_default();
141    Ok(RouteReplay { amount_out, gas: total_gas })
142}
143
144#[cfg(test)]
145mod tests {
146    use rustc_hash::FxHashMap;
147
148    use super::*;
149    use crate::algorithm::test_utils::{component, token, ConstantProductSim, MockProtocolSim};
150
151    fn make_market(
152        pools: Vec<(
153            &str,
154            Vec<tycho_simulation::tycho_common::models::token::Token>,
155            Box<dyn ProtocolSim>,
156        )>,
157    ) -> MarketState {
158        let mut market = MarketState::new();
159        for (pool_id, tokens, sim) in pools {
160            market.upsert_components(std::iter::once(component(pool_id, &tokens)));
161            market.update_states([(pool_id.to_string(), sim)]);
162            market.upsert_tokens(tokens);
163        }
164        market
165    }
166
167    /// A swap as a route would carry it. The embedded protocol state is a decoy with an absurd
168    /// price so any test that accidentally simulates against it fails loudly.
169    fn route_swap(
170        pool_id: &str,
171        token_in: &tycho_simulation::tycho_common::models::token::Token,
172        token_out: &tycho_simulation::tycho_common::models::token::Token,
173        amount_in: u64,
174        split: f64,
175    ) -> Swap {
176        Swap::new(
177            pool_id.to_string(),
178            "mock".to_string(),
179            token_in.address.clone(),
180            token_out.address.clone(),
181            BigUint::from(amount_in),
182            BigUint::ZERO,
183            BigUint::ZERO,
184            component(pool_id, &[token_in.clone(), token_out.clone()]),
185            Box::new(MockProtocolSim::new(1_000_000.0)),
186        )
187        .with_split(split)
188    }
189
190    fn route(swaps: Vec<Swap>) -> Route {
191        Route::new(swaps, FxHashMap::default()).expect("test route must not be empty")
192    }
193
194    #[test]
195    fn sequential_route_threads_amounts_through_market_state() {
196        // A→B→C at market prices 2.0 then 3.0: 1000 → 2000 → 6000. The swaps embed a decoy
197        // state, so this also proves replay reads the market, not the route's quote-time states.
198        let token_a = token(0x0A, "A");
199        let token_b = token(0x0B, "B");
200        let token_c = token(0x0C, "C");
201        let market = make_market(vec![
202            (
203                "pool_ab",
204                vec![token_a.clone(), token_b.clone()],
205                Box::new(MockProtocolSim::new(2.0).with_gas(50_000)),
206            ),
207            (
208                "pool_bc",
209                vec![token_b.clone(), token_c.clone()],
210                Box::new(MockProtocolSim::new(3.0).with_gas(70_000)),
211            ),
212        ]);
213        let route = route(vec![
214            route_swap("pool_ab", &token_a, &token_b, 1_000, 0.0),
215            route_swap("pool_bc", &token_b, &token_c, 2_000, 0.0),
216        ]);
217
218        let replay = replay_route(&route, &market).unwrap();
219        assert_eq!(replay.amount_out, BigUint::from(6_000u64));
220        assert_eq!(replay.gas, BigUint::from(120_000u64));
221    }
222
223    #[test]
224    fn split_route_divides_by_fraction_with_remainder() {
225        // 1000 split 60/40 across two parallel pools: 600*2 + 400*3 = 2400. The second swap
226        // carries split 0.0 (remainder convention).
227        let token_a = token(0x0A, "A");
228        let token_b = token(0x0B, "B");
229        let market = make_market(vec![
230            ("pool_1", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(2.0))),
231            ("pool_2", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(3.0))),
232        ]);
233        let route = route(vec![
234            route_swap("pool_1", &token_a, &token_b, 600, 0.6),
235            route_swap("pool_2", &token_a, &token_b, 400, 0.0),
236        ]);
237
238        let replay = replay_route(&route, &market).unwrap();
239        assert_eq!(replay.amount_out, BigUint::from(2_400u64));
240    }
241
242    #[test]
243    fn splits_are_fractions_of_the_collected_total_not_the_remainder() {
244        // Three-way split 0.5 / 0.3 / remainder of 1000: 500, 300, 200 — the 0.3 applies to the
245        // full 1000, not to the 500 left after the first swap.
246        let token_a = token(0x0A, "A");
247        let token_b = token(0x0B, "B");
248        let market = make_market(vec![
249            ("pool_1", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(1.0))),
250            ("pool_2", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(2.0))),
251            ("pool_3", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(4.0))),
252        ]);
253        let route = route(vec![
254            route_swap("pool_1", &token_a, &token_b, 500, 0.5),
255            route_swap("pool_2", &token_a, &token_b, 300, 0.3),
256            route_swap("pool_3", &token_a, &token_b, 200, 0.0),
257        ]);
258
259        // 500*1 + 300*2 + 200*4 = 1900.
260        let replay = replay_route(&route, &market).unwrap();
261        assert_eq!(replay.amount_out, BigUint::from(1_900u64));
262    }
263
264    #[test]
265    fn shared_pool_sees_depleted_reserves() {
266        // Two swaps through the same constant-product pool: the second must run on the first's
267        // post-swap reserves, matching one full-amount swap up to rounding.
268        let token_a = token(0x0A, "A");
269        let token_b = token(0x0B, "B");
270        let cp = ConstantProductSim {
271            reserve_0: BigUint::from(10_000u64),
272            reserve_1: BigUint::from(10_000u64),
273            gas: 50_000,
274        };
275        let market = make_market(vec![(
276            "pool",
277            vec![token_a.clone(), token_b.clone()],
278            Box::new(cp.clone()),
279        )]);
280        let route = route(vec![
281            route_swap("pool", &token_a, &token_b, 500, 0.5),
282            route_swap("pool", &token_a, &token_b, 500, 0.0),
283        ]);
284
285        let replay = replay_route(&route, &market).unwrap();
286        let full_swap = cp
287            .get_amount_out(BigUint::from(1_000u64), &token_a, &token_b)
288            .unwrap()
289            .amount;
290        let diff = if replay.amount_out > full_swap {
291            &replay.amount_out - &full_swap
292        } else {
293            &full_swap - &replay.amount_out
294        };
295        assert!(diff <= BigUint::from(2u32), "split through one pool must match one full swap");
296    }
297
298    #[test]
299    fn changed_market_state_changes_the_output() {
300        // The pool moved in the trade's favor between quote and replay: same route, more output.
301        let token_a = token(0x0A, "A");
302        let token_b = token(0x0B, "B");
303        let market = make_market(vec![(
304            "pool",
305            vec![token_a.clone(), token_b.clone()],
306            Box::new(MockProtocolSim::new(2.5)),
307        )]);
308        // The route was quoted at price 2.0 (amount_out recorded then was 2000).
309        let route = route(vec![route_swap("pool", &token_a, &token_b, 1_000, 0.0)]);
310
311        let replay = replay_route(&route, &market).unwrap();
312        assert_eq!(replay.amount_out, BigUint::from(2_500u64));
313    }
314
315    #[test]
316    fn missing_simulation_state_errors() {
317        let token_a = token(0x0A, "A");
318        let token_b = token(0x0B, "B");
319        let mut market = make_market(vec![]);
320        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
321        let route = route(vec![route_swap("gone", &token_a, &token_b, 1_000, 0.0)]);
322
323        let err = replay_route(&route, &market).unwrap_err();
324        assert!(matches!(err, ReplayError::MissingState(id) if id == "gone"));
325    }
326
327    #[test]
328    fn missing_token_errors() {
329        let token_a = token(0x0A, "A");
330        let token_b = token(0x0B, "B");
331        let market = make_market(vec![(
332            "pool",
333            vec![token_a.clone(), token_b.clone()],
334            Box::new(MockProtocolSim::new(2.0)),
335        )]);
336        let unknown = token(0x42, "X");
337        let route = route(vec![route_swap("pool", &unknown, &token_b, 1_000, 0.0)]);
338
339        let err = replay_route(&route, &market).unwrap_err();
340        assert!(matches!(err, ReplayError::MissingToken(addr) if addr == unknown.address));
341    }
342}