Skip to main content

fynd_core/algorithm/
swap_cache.rs

1use num_bigint::BigUint;
2use rustc_hash::FxHashMap;
3use tycho_simulation::tycho_common::{models::Address, simulation::errors::SimulationError};
4
5use crate::{algorithm::sim_meter, ComponentId};
6
7/// How far apart the two amounts either side of a requested one may sit, as a percentage of the
8/// amount requested, before ranking stops reading across them and asks the pool instead.
9const INTERPOLATION_GAP_PERCENT: u32 = 10;
10
11/// A pool and the direction taken through it. Both token addresses are part of it — a pool trading
12/// three tokens answers `USDC -> DAI` and `USDT -> DAI` differently for the same amount.
13#[derive(PartialEq, Eq, Hash)]
14pub struct PoolDirection<'a> {
15    /// The pool being asked.
16    pub component_id: &'a ComponentId,
17    /// Token going in.
18    pub address_in: &'a Address,
19    /// Token coming out.
20    pub address_out: &'a Address,
21}
22
23/// Why a pool paid nothing for an amount.
24///
25/// A pool that turns an amount down for being more than it can serve turns down every larger
26/// amount too, and that is worth remembering. A pool that simply failed says nothing about any
27/// other amount — the panic guard turns a component that blew up on one input into an error like
28/// any other, and reading a size limit out of that would drop a working pool for the whole solve.
29#[derive(Clone, Copy, PartialEq, Eq)]
30pub enum Refusal {
31    /// The pool rejected the input itself, which is how it reports an amount beyond its limit.
32    OverLimit,
33    /// The pool failed for a reason that carries no information about size.
34    Failed,
35}
36
37impl Refusal {
38    /// Reads a failed simulation. Only the pool rejecting the input says anything about size;
39    /// a fatal error is what a caught panic arrives as, and a recoverable one is transient.
40    pub fn of(error: &SimulationError) -> Self {
41        match error {
42            SimulationError::InvalidInput(_, _) => Refusal::OverLimit,
43            SimulationError::FatalError(_) | SimulationError::RecoverableError(_) => {
44                Refusal::Failed
45            }
46        }
47    }
48}
49
50/// What a swap paid: what came out, and the gas it cost.
51#[derive(Clone)]
52pub struct SwapResult {
53    /// What the pool paid out.
54    pub amount_out: BigUint,
55    /// What the swap costs in gas units.
56    pub gas: BigUint,
57}
58
59/// What one pool paid at each amount it was asked, ascending by amount. A missing outcome is an
60/// amount it refused.
61///
62/// Short by nature — a handful of amounts per direction over one solve — so a sorted `Vec` searched
63/// by bisection beats a map, and inserting in place keeps the neighbours of any amount adjacent.
64#[derive(Default)]
65pub struct SwappedAmounts {
66    /// Sorted ascending by amount!
67    amounts_and_results: Vec<(BigUint, Option<SwapResult>)>,
68    /// The amount from which this pool has been taken to refuse everything. See
69    /// [`SwappedAmounts::record`] for what has to hold before an amount is recorded here.
70    failed_at: Option<BigUint>,
71}
72
73impl SwappedAmounts {
74    /// Whether `amount_in` is at or above the point this pool started refusing.
75    fn refuses(&self, amount_in: &BigUint) -> bool {
76        self.failed_at
77            .as_ref()
78            .is_some_and(|refused_from| amount_in >= refused_from)
79    }
80
81    /// Records what the pool paid for `amount_in`.
82    ///
83    /// A pool that turns an amount down for being over its limit turns down every larger amount
84    /// too, so `refused_from` is set to this amount (or lowered to it) and larger amounts are
85    /// refused without asking the pool.
86    ///
87    /// Three things must hold first, because a refusal is not always about size:
88    ///
89    /// * the pool rejected the input itself rather than failing ([`Refusal::OverLimit`]) — a pool
90    ///   that blew up on one input still serves every other;
91    /// * the pool served some smaller amount, so this really is a limit — a swap can also fail for
92    ///   being too small to quote, and that fails at the opposite end;
93    /// * the pool served no larger amount, which would prove it does not refuse by size at all.
94    fn record(
95        &mut self,
96        insert_at: usize,
97        amount_in: &BigUint,
98        outcome: Result<SwapResult, Refusal>,
99    ) {
100        let refusal = outcome.as_ref().err().copied();
101        self.amounts_and_results
102            .insert(insert_at, (amount_in.clone(), outcome.ok()));
103        if refusal != Some(Refusal::OverLimit) {
104            return;
105        }
106
107        let was_served = |(_, outcome): &(BigUint, Option<SwapResult>)| outcome.is_some();
108        let served_below = self.amounts_and_results[..insert_at]
109            .iter()
110            .any(was_served);
111        let served_above = self.amounts_and_results[insert_at + 1..]
112            .iter()
113            .any(was_served);
114        if !served_below || served_above {
115            return;
116        }
117
118        let lowest_refused = match self.failed_at.take() {
119            Some(already_refused) if already_refused <= *amount_in => already_refused,
120            _ => amount_in.clone(),
121        };
122        self.failed_at = Some(lowest_refused);
123    }
124}
125
126/// Swaps already made, so a pool asked the same question twice is only simulated once.
127///
128/// **Only for swaps that read untouched component state.** Every answer here is kept for the whole
129/// solve, which is sound exactly while nothing commits a swap back into the state being read. The
130/// chunked water-fills do commit — they ask one pool the same question repeatedly and depend on a
131/// worse answer each time as it is drained — so they simulate against their own overlay and must
132/// not come through here.
133pub struct SwapCache<'a> {
134    by_direction: FxHashMap<PoolDirection<'a>, SwappedAmounts>,
135}
136
137impl Default for SwapCache<'_> {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl<'a> SwapCache<'a> {
144    /// An empty cache, for one solve.
145    #[must_use]
146    pub fn new() -> Self {
147        Self { by_direction: FxHashMap::default() }
148    }
149
150    /// What `direction` pays for `amount_in`.
151    ///
152    /// Answers from the amounts already asked of that pool where it can, reading across two of them
153    /// when the asking pass allows it, and otherwise calls `simulate` and keeps the result. Every
154    /// route through here is booked against the component and the pass, so the report separates
155    /// what was simulated from what was reused and from what was read across.
156    pub fn swap(
157        &mut self,
158        direction: PoolDirection<'a>,
159        amount_in: &BigUint,
160        label: &'static str,
161        simulate: impl FnOnce() -> Result<SwapResult, Refusal>,
162        may_interpolate: bool,
163    ) -> Option<SwapResult> {
164        let component_id = direction.component_id;
165        let amounts_swapped = self
166            .by_direction
167            .entry(direction)
168            .or_default();
169
170        let insert_at = match amounts_swapped
171            .amounts_and_results
172            .binary_search_by(|(amount, _)| amount.cmp(amount_in))
173        {
174            Ok(asked_before) => {
175                sim_meter::record_cache_hit(component_id, label);
176                return amounts_swapped.amounts_and_results[asked_before]
177                    .1
178                    .clone();
179            }
180            Err(insert_at) => insert_at,
181        };
182
183        // Asking a pool for more than it has already turned down buys the same refusal again, and
184        // on a `vm:` pool that is as expensive as a swap it would have served.
185        if amounts_swapped.refuses(amount_in) {
186            sim_meter::record_refusal_without_calling(component_id, label);
187            return None;
188        }
189
190        if may_interpolate {
191            if let Some(read_across) = Self::interpolate(amounts_swapped, insert_at, amount_in) {
192                sim_meter::record_interpolation(component_id, label);
193                return Some(read_across);
194            }
195        }
196
197        // Only a simulated amount is kept. Keeping one that was itself read across would let the
198        // error compound, each reading drifting further from the pool's own curve.
199        let outcome = simulate();
200        amounts_swapped.record(insert_at, amount_in, outcome.clone());
201        outcome.ok()
202    }
203
204    /// What the pool would pay for `amount_in`, read across the amounts either side of it.
205    ///
206    /// Output against input is concave for a pool — each further unit in buys less out — so the
207    /// straight line between two amounts runs below the pool's own curve. Reading across it
208    /// therefore comes out a little low, never high, and a path can only lose a ranking it
209    /// deserved rather than win one it did not.
210    ///
211    /// That only holds between two amounts. Past the largest one asked, the same line runs above
212    /// the curve, because it carries a price the pool no longer offers — so those are simulated.
213    ///
214    /// The gap between the two amounts must also be within [`INTERPOLATION_GAP_PERCENT`] of the
215    /// amount asked for.
216    /// A wider gap is where the line drifts furthest from the curve, and the gap narrows on its
217    /// own: a request this turns away is simulated, and that amount lands between the two,
218    /// leaving a closer pair behind for the next pass.
219    ///
220    /// Gas takes the larger amount's figure, not the nearer one's. Gas does not climb smoothly — a
221    /// crossed tick costs what it costs — and a swap never needs less gas than a smaller one, so
222    /// the larger amount's figure is never under the truth. Ranking subtracts gas from output, so
223    /// understating it here would overstate a path's net and let it take a place it had not
224    /// earned, which is the one thing this must not do.
225    fn interpolate(
226        amounts_swapped: &SwappedAmounts,
227        insert_at: usize,
228        amount_in: &BigUint,
229    ) -> Option<SwapResult> {
230        let (lower_amount, lower) = amounts_swapped
231            .amounts_and_results
232            .get(insert_at.checked_sub(1)?)?;
233        let (upper_amount, upper) = amounts_swapped
234            .amounts_and_results
235            .get(insert_at)?;
236        let (lower, upper) = (lower.as_ref()?, upper.as_ref()?);
237
238        let amount_gap = upper_amount - lower_amount;
239        if &amount_gap * 100u32 > amount_in * INTERPOLATION_GAP_PERCENT {
240            return None;
241        }
242        // A pool paying less for more is not the concave curve this reads across.
243        if upper.amount_out < lower.amount_out {
244            return None;
245        }
246
247        let output_gap = &upper.amount_out - &lower.amount_out;
248        let amount_past_lower = amount_in - lower_amount;
249        let amount_out = &lower.amount_out + output_gap * amount_past_lower / amount_gap;
250        Some(SwapResult { amount_out, gas: upper.gas.clone() })
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::algorithm::test_utils::addr;
258
259    /// Stage labels the cache is asked under. The cache only groups by the label and obeys the
260    /// interpolation flag, so the tests name them here rather than reaching for a solve's stages.
261    const RANKING: &str = "ranking";
262    const COMMITTING: &str = "chunking";
263    const EXCHANGE: &str = "exchange";
264    const INTERPOLATES: bool = true;
265    const NO_INTERPOLATION: bool = false;
266
267    // ==================== Reading across known amounts ====================
268
269    fn hop(amount_out: u64, gas: u64) -> SwapResult {
270        SwapResult { amount_out: BigUint::from(amount_out), gas: BigUint::from(gas) }
271    }
272
273    /// A cache holding `amounts` for one pool direction, with no refusal point recorded.
274    fn cache_holding(amounts: Vec<(u64, Option<SwapResult>)>) -> SwappedAmounts {
275        SwappedAmounts {
276            amounts_and_results: amounts
277                .into_iter()
278                .map(|(amount, outcome)| (BigUint::from(amount), outcome))
279                .collect(),
280            failed_at: None,
281        }
282    }
283
284    /// Where `amount` would be inserted into a cache's ascending amounts.
285    fn insert_at(swapped: &SwappedAmounts, amount: &BigUint) -> usize {
286        swapped
287            .amounts_and_results
288            .binary_search_by(|(known, _)| known.cmp(amount))
289            .expect_err("amount must not already be recorded")
290    }
291
292    fn read_across(swapped: &SwappedAmounts, amount: u64) -> Option<SwapResult> {
293        let amount = BigUint::from(amount);
294        SwapCache::interpolate(swapped, insert_at(swapped, &amount), &amount)
295    }
296
297    /// Halfway between two amounts reads back halfway between their outputs.
298    #[test]
299    fn test_interpolate_reads_across_two_amounts() {
300        let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, Some(hop(2080, 90)))]);
301
302        let across = read_across(&swapped, 1020).expect("bracketed and inside the gap");
303
304        assert_eq!(across.amount_out, BigUint::from(2040u64));
305    }
306
307    /// Gas comes from the larger amount, never the nearer one: understating gas would overstate a
308    /// path's output net of gas, which is the one direction reading across must not err in.
309    #[test]
310    fn test_interpolate_takes_gas_from_the_larger_amount() {
311        let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, Some(hop(2080, 90)))]);
312
313        let nearer_the_lower = read_across(&swapped, 1001).expect("bracketed and inside the gap");
314
315        assert_eq!(nearer_the_lower.gas, BigUint::from(90u64));
316    }
317
318    /// Amounts further apart than `INTERPOLATION_GAP_PERCENT` are left to the pool.
319    #[test]
320    fn test_interpolate_declines_a_wide_gap() {
321        let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1500, Some(hop(2600, 90)))]);
322
323        assert!(read_across(&swapped, 1200).is_none());
324    }
325
326    /// Above every amount asked, the straight line carries a price the pool no longer offers, so
327    /// there is nothing to read across.
328    #[test]
329    fn test_interpolate_declines_above_the_largest_amount() {
330        let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, Some(hop(2080, 90)))]);
331
332        assert!(read_across(&swapped, 1050).is_none());
333    }
334
335    /// A pool paying less for more is not the curve this reads across.
336    #[test]
337    fn test_interpolate_declines_when_output_falls() {
338        let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, Some(hop(1900, 90)))]);
339
340        assert!(read_across(&swapped, 1020).is_none());
341    }
342
343    /// A refused amount either side leaves nothing to read across.
344    #[test]
345    fn test_interpolate_declines_across_a_refusal() {
346        let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, None)]);
347
348        assert!(read_across(&swapped, 1020).is_none());
349    }
350
351    // ==================== Refusals reaching upwards ====================
352
353    fn record(swapped: &mut SwappedAmounts, amount: u64, outcome: Result<SwapResult, Refusal>) {
354        let amount = BigUint::from(amount);
355        let at = insert_at(swapped, &amount);
356        swapped.record(at, &amount, outcome);
357    }
358
359    /// A refusal above an amount the pool served is taken to refuse everything larger.
360    #[test]
361    fn test_refusal_above_a_served_amount_reaches_upwards() {
362        let mut swapped = cache_holding(vec![(1000, Some(hop(2000, 50)))]);
363
364        record(&mut swapped, 2000, Err(Refusal::OverLimit));
365
366        assert!(swapped.refuses(&BigUint::from(2000u64)));
367        assert!(swapped.refuses(&BigUint::from(5000u64)));
368        assert!(!swapped.refuses(&BigUint::from(1500u64)));
369    }
370
371    /// A refusal with nothing served below it may be an amount too small to quote rather than a
372    /// limit, so it stands only for itself.
373    #[test]
374    fn test_refusal_with_nothing_served_below_stands_alone() {
375        let mut swapped = cache_holding(vec![]);
376
377        record(&mut swapped, 1000, Err(Refusal::OverLimit));
378
379        assert!(!swapped.refuses(&BigUint::from(5000u64)));
380    }
381
382    /// A larger amount the pool did serve says it does not refuse upwards at all.
383    #[test]
384    fn test_refusal_below_a_served_amount_does_not_reach_upwards() {
385        let mut swapped =
386            cache_holding(vec![(1000, Some(hop(2000, 50))), (3000, Some(hop(5000, 50)))]);
387
388        record(&mut swapped, 2000, Err(Refusal::OverLimit));
389
390        assert!(!swapped.refuses(&BigUint::from(4000u64)));
391    }
392
393    // ==================== The cache deciding when a pool is called ====================
394
395    /// Drives `SwapCache::swap` with a closure that counts its calls, so a test can assert whether
396    /// the pool was reached at all — which is the whole point of the cache.
397    struct CountingPool {
398        component_id: ComponentId,
399        address_in: Address,
400        address_out: Address,
401        calls: std::cell::Cell<usize>,
402        answer: Option<SwapResult>,
403    }
404
405    impl CountingPool {
406        fn paying(amount_out: u64) -> Self {
407            Self {
408                component_id: ComponentId::from("pool"),
409                address_in: addr(0x01),
410                address_out: addr(0x02),
411                calls: std::cell::Cell::new(0),
412                answer: Some(hop(amount_out, 10)),
413            }
414        }
415
416        fn refusing() -> Self {
417            Self { answer: None, ..Self::paying(0) }
418        }
419
420        fn direction(&self) -> PoolDirection<'_> {
421            PoolDirection {
422                component_id: &self.component_id,
423                address_in: &self.address_in,
424                address_out: &self.address_out,
425            }
426        }
427
428        fn ask<'a>(
429            &'a self,
430            cache: &mut SwapCache<'a>,
431            amount: u64,
432            label: &'static str,
433            may_interpolate: bool,
434        ) -> Option<SwapResult> {
435            cache.swap(
436                self.direction(),
437                &BigUint::from(amount),
438                label,
439                || {
440                    self.calls.set(self.calls.get() + 1);
441                    self.answer
442                        .clone()
443                        .ok_or(Refusal::OverLimit)
444                },
445                may_interpolate,
446            )
447        }
448    }
449
450    /// The same amount asked twice reaches the pool once; the second answer is the first one.
451    #[test]
452    fn test_swap_answers_a_repeated_amount_without_calling() {
453        let pool = CountingPool::paying(2000);
454        let mut cache = SwapCache::new();
455
456        let first = pool.ask(&mut cache, 1000, COMMITTING, NO_INTERPOLATION);
457        let second = pool.ask(&mut cache, 1000, COMMITTING, NO_INTERPOLATION);
458
459        assert_eq!(pool.calls.get(), 1);
460        assert_eq!(first.map(|h| h.amount_out), Some(BigUint::from(2000u64)));
461        assert_eq!(second.map(|h| h.amount_out), Some(BigUint::from(2000u64)));
462    }
463
464    /// Past an amount the pool refused for being over its limit, larger amounts are refused
465    /// without asking it again.
466    #[test]
467    fn test_swap_short_circuits_above_a_refusal() {
468        let pool = CountingPool::refusing();
469        let mut cache = SwapCache::new();
470        // Serve a smaller amount first: a refusal only reaches upwards once one below it worked.
471        cache.swap(
472            pool.direction(),
473            &BigUint::from(500u64),
474            COMMITTING,
475            || Ok(hop(1000, 10)),
476            NO_INTERPOLATION,
477        );
478        pool.ask(&mut cache, 1000, COMMITTING, NO_INTERPOLATION);
479        let calls_after_refusal = pool.calls.get();
480
481        let larger = pool.ask(&mut cache, 5000, COMMITTING, NO_INTERPOLATION);
482
483        assert!(larger.is_none());
484        assert_eq!(pool.calls.get(), calls_after_refusal, "the pool was asked again");
485    }
486
487    /// A pass that may read across two nearby amounts gets an answer without a call; one that may
488    /// not is simulated for real. This is the gate that keeps approximated amounts out of the
489    /// passes that commit and report.
490    #[test]
491    fn test_swap_interpolates_only_for_a_pass_that_allows_it() {
492        let interpolating = CountingPool::paying(0);
493        let mut cache = SwapCache::new();
494        cache.swap(
495            interpolating.direction(),
496            &BigUint::from(1000u64),
497            RANKING,
498            || Ok(hop(1000, 10)),
499            INTERPOLATES,
500        );
501        cache.swap(
502            interpolating.direction(),
503            &BigUint::from(1100u64),
504            RANKING,
505            || Ok(hop(1100, 10)),
506            INTERPOLATES,
507        );
508        let calls_before = interpolating.calls.get();
509
510        let read_across = interpolating.ask(&mut cache, 1050, RANKING, INTERPOLATES);
511        assert_eq!(interpolating.calls.get(), calls_before, "ranking should not have called");
512        assert_eq!(read_across.map(|h| h.amount_out), Some(BigUint::from(1050u64)));
513
514        let simulated = interpolating.ask(&mut cache, 1060, EXCHANGE, NO_INTERPOLATION);
515        assert_eq!(interpolating.calls.get(), calls_before + 1, "exchange must call the pool");
516        assert_eq!(simulated.map(|h| h.amount_out), Some(BigUint::from(0u64)));
517    }
518
519    /// An interpolated answer is not kept, so it can never be read across again and compound. The
520    /// same request a second time still reaches the pool.
521    #[test]
522    fn test_swap_does_not_store_an_interpolated_answer() {
523        let pool = CountingPool::paying(7777);
524        let mut cache = SwapCache::new();
525        cache.swap(
526            pool.direction(),
527            &BigUint::from(1000u64),
528            RANKING,
529            || Ok(hop(1000, 10)),
530            INTERPOLATES,
531        );
532        cache.swap(
533            pool.direction(),
534            &BigUint::from(1100u64),
535            RANKING,
536            || Ok(hop(1100, 10)),
537            INTERPOLATES,
538        );
539        pool.ask(&mut cache, 1050, RANKING, INTERPOLATES);
540
541        let asked_again = pool.ask(&mut cache, 1050, EXCHANGE, NO_INTERPOLATION);
542
543        assert_eq!(pool.calls.get(), 1, "the interpolated answer should not have been stored");
544        assert_eq!(asked_again.map(|h| h.amount_out), Some(BigUint::from(7777u64)));
545    }
546
547    /// A pool that failed rather than rejected the amount says nothing about larger amounts: the
548    /// panic guard reports a component that blew up on one input as an error like any other, and
549    /// reading a limit out of that would drop a working pool for the rest of the solve.
550    #[test]
551    fn test_failure_that_is_not_a_limit_does_not_reach_upwards() {
552        let mut swapped = cache_holding(vec![(1000, Some(hop(2000, 50)))]);
553
554        record(&mut swapped, 2000, Err(Refusal::Failed));
555
556        assert!(!swapped.refuses(&BigUint::from(2000u64)));
557        assert!(!swapped.refuses(&BigUint::from(5000u64)));
558    }
559
560    /// A second, lower refusal moves the point everything above is refused from down to it.
561    #[test]
562    fn test_lower_refusal_moves_the_refusal_point_down() {
563        let mut swapped = cache_holding(vec![(1000, Some(hop(2000, 50)))]);
564
565        record(&mut swapped, 3000, Err(Refusal::OverLimit));
566        record(&mut swapped, 2000, Err(Refusal::OverLimit));
567
568        assert!(swapped.refuses(&BigUint::from(2000u64)));
569    }
570}