use num_bigint::BigUint;
use rustc_hash::FxHashMap;
use tycho_simulation::tycho_common::{models::Address, simulation::errors::SimulationError};
use crate::{algorithm::sim_meter, ComponentId};
const INTERPOLATION_GAP_PERCENT: u32 = 10;
#[derive(PartialEq, Eq, Hash)]
pub struct PoolDirection<'a> {
pub(crate) component_id: &'a ComponentId,
pub(crate) address_in: &'a Address,
pub(crate) address_out: &'a Address,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
OverLimit,
Failed,
}
impl Refusal {
pub(crate) fn of(error: &SimulationError) -> Self {
match error {
SimulationError::InvalidInput(_, _) => Refusal::OverLimit,
SimulationError::FatalError(_) | SimulationError::RecoverableError(_) => {
Refusal::Failed
}
}
}
}
#[derive(Clone)]
pub struct SwapResult {
pub(crate) amount_out: BigUint,
pub(crate) gas: BigUint,
}
#[derive(Default)]
pub struct SwappedAmounts {
amounts_and_results: Vec<(BigUint, Option<SwapResult>)>,
failed_at: Option<BigUint>,
}
impl SwappedAmounts {
fn refuses(&self, amount_in: &BigUint) -> bool {
self.failed_at
.as_ref()
.is_some_and(|refused_from| amount_in >= refused_from)
}
fn record(
&mut self,
insert_at: usize,
amount_in: &BigUint,
outcome: Result<SwapResult, Refusal>,
) {
let refusal = outcome.as_ref().err().copied();
self.amounts_and_results
.insert(insert_at, (amount_in.clone(), outcome.ok()));
if refusal != Some(Refusal::OverLimit) {
return;
}
let was_served = |(_, outcome): &(BigUint, Option<SwapResult>)| outcome.is_some();
let served_below = self.amounts_and_results[..insert_at]
.iter()
.any(was_served);
let served_above = self.amounts_and_results[insert_at + 1..]
.iter()
.any(was_served);
if !served_below || served_above {
return;
}
let lowest_refused = match self.failed_at.take() {
Some(already_refused) if already_refused <= *amount_in => already_refused,
_ => amount_in.clone(),
};
self.failed_at = Some(lowest_refused);
}
}
pub struct SwapCache<'a> {
by_direction: FxHashMap<PoolDirection<'a>, SwappedAmounts>,
}
impl<'a> SwapCache<'a> {
pub(crate) fn new() -> Self {
Self { by_direction: FxHashMap::default() }
}
pub(crate) fn swap(
&mut self,
direction: PoolDirection<'a>,
amount_in: &BigUint,
label: &'static str,
simulate: impl FnOnce() -> Result<SwapResult, Refusal>,
may_interpolate: bool,
) -> Option<SwapResult> {
let component_id = direction.component_id;
let amounts_swapped = self
.by_direction
.entry(direction)
.or_default();
let insert_at = match amounts_swapped
.amounts_and_results
.binary_search_by(|(amount, _)| amount.cmp(amount_in))
{
Ok(asked_before) => {
sim_meter::record_cache_hit(component_id, label);
return amounts_swapped.amounts_and_results[asked_before]
.1
.clone();
}
Err(insert_at) => insert_at,
};
if amounts_swapped.refuses(amount_in) {
sim_meter::record_refusal_without_calling(component_id, label);
return None;
}
if may_interpolate {
if let Some(read_across) = Self::interpolate(amounts_swapped, insert_at, amount_in) {
sim_meter::record_interpolation(component_id, label);
return Some(read_across);
}
}
let outcome = simulate();
amounts_swapped.record(insert_at, amount_in, outcome.clone());
outcome.ok()
}
fn interpolate(
amounts_swapped: &SwappedAmounts,
insert_at: usize,
amount_in: &BigUint,
) -> Option<SwapResult> {
let (lower_amount, lower) = amounts_swapped
.amounts_and_results
.get(insert_at.checked_sub(1)?)?;
let (upper_amount, upper) = amounts_swapped
.amounts_and_results
.get(insert_at)?;
let (lower, upper) = (lower.as_ref()?, upper.as_ref()?);
let amount_gap = upper_amount - lower_amount;
if &amount_gap * 100u32 > amount_in * INTERPOLATION_GAP_PERCENT {
return None;
}
if upper.amount_out < lower.amount_out {
return None;
}
let output_gap = &upper.amount_out - &lower.amount_out;
let amount_past_lower = amount_in - lower_amount;
let amount_out = &lower.amount_out + output_gap * amount_past_lower / amount_gap;
Some(SwapResult { amount_out, gas: upper.gas.clone() })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::algorithm::test_utils::addr;
const RANKING: &str = "ranking";
const COMMITTING: &str = "chunking";
const EXCHANGE: &str = "exchange";
const INTERPOLATES: bool = true;
const NO_INTERPOLATION: bool = false;
fn hop(amount_out: u64, gas: u64) -> SwapResult {
SwapResult { amount_out: BigUint::from(amount_out), gas: BigUint::from(gas) }
}
fn cache_holding(amounts: Vec<(u64, Option<SwapResult>)>) -> SwappedAmounts {
SwappedAmounts {
amounts_and_results: amounts
.into_iter()
.map(|(amount, outcome)| (BigUint::from(amount), outcome))
.collect(),
failed_at: None,
}
}
fn insert_at(swapped: &SwappedAmounts, amount: &BigUint) -> usize {
swapped
.amounts_and_results
.binary_search_by(|(known, _)| known.cmp(amount))
.expect_err("amount must not already be recorded")
}
fn read_across(swapped: &SwappedAmounts, amount: u64) -> Option<SwapResult> {
let amount = BigUint::from(amount);
SwapCache::interpolate(swapped, insert_at(swapped, &amount), &amount)
}
#[test]
fn test_interpolate_reads_across_two_amounts() {
let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, Some(hop(2080, 90)))]);
let across = read_across(&swapped, 1020).expect("bracketed and inside the gap");
assert_eq!(across.amount_out, BigUint::from(2040u64));
}
#[test]
fn test_interpolate_takes_gas_from_the_larger_amount() {
let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, Some(hop(2080, 90)))]);
let nearer_the_lower = read_across(&swapped, 1001).expect("bracketed and inside the gap");
assert_eq!(nearer_the_lower.gas, BigUint::from(90u64));
}
#[test]
fn test_interpolate_declines_a_wide_gap() {
let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1500, Some(hop(2600, 90)))]);
assert!(read_across(&swapped, 1200).is_none());
}
#[test]
fn test_interpolate_declines_above_the_largest_amount() {
let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, Some(hop(2080, 90)))]);
assert!(read_across(&swapped, 1050).is_none());
}
#[test]
fn test_interpolate_declines_when_output_falls() {
let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, Some(hop(1900, 90)))]);
assert!(read_across(&swapped, 1020).is_none());
}
#[test]
fn test_interpolate_declines_across_a_refusal() {
let swapped = cache_holding(vec![(1000, Some(hop(2000, 50))), (1040, None)]);
assert!(read_across(&swapped, 1020).is_none());
}
fn record(swapped: &mut SwappedAmounts, amount: u64, outcome: Result<SwapResult, Refusal>) {
let amount = BigUint::from(amount);
let at = insert_at(swapped, &amount);
swapped.record(at, &amount, outcome);
}
#[test]
fn test_refusal_above_a_served_amount_reaches_upwards() {
let mut swapped = cache_holding(vec![(1000, Some(hop(2000, 50)))]);
record(&mut swapped, 2000, Err(Refusal::OverLimit));
assert!(swapped.refuses(&BigUint::from(2000u64)));
assert!(swapped.refuses(&BigUint::from(5000u64)));
assert!(!swapped.refuses(&BigUint::from(1500u64)));
}
#[test]
fn test_refusal_with_nothing_served_below_stands_alone() {
let mut swapped = cache_holding(vec![]);
record(&mut swapped, 1000, Err(Refusal::OverLimit));
assert!(!swapped.refuses(&BigUint::from(5000u64)));
}
#[test]
fn test_refusal_below_a_served_amount_does_not_reach_upwards() {
let mut swapped =
cache_holding(vec![(1000, Some(hop(2000, 50))), (3000, Some(hop(5000, 50)))]);
record(&mut swapped, 2000, Err(Refusal::OverLimit));
assert!(!swapped.refuses(&BigUint::from(4000u64)));
}
struct CountingPool {
component_id: ComponentId,
address_in: Address,
address_out: Address,
calls: std::cell::Cell<usize>,
answer: Option<SwapResult>,
}
impl CountingPool {
fn paying(amount_out: u64) -> Self {
Self {
component_id: ComponentId::from("pool"),
address_in: addr(0x01),
address_out: addr(0x02),
calls: std::cell::Cell::new(0),
answer: Some(hop(amount_out, 10)),
}
}
fn refusing() -> Self {
Self { answer: None, ..Self::paying(0) }
}
fn direction(&self) -> PoolDirection<'_> {
PoolDirection {
component_id: &self.component_id,
address_in: &self.address_in,
address_out: &self.address_out,
}
}
fn ask<'a>(
&'a self,
cache: &mut SwapCache<'a>,
amount: u64,
label: &'static str,
may_interpolate: bool,
) -> Option<SwapResult> {
cache.swap(
self.direction(),
&BigUint::from(amount),
label,
|| {
self.calls.set(self.calls.get() + 1);
self.answer
.clone()
.ok_or(Refusal::OverLimit)
},
may_interpolate,
)
}
}
#[test]
fn test_swap_answers_a_repeated_amount_without_calling() {
let pool = CountingPool::paying(2000);
let mut cache = SwapCache::new();
let first = pool.ask(&mut cache, 1000, COMMITTING, NO_INTERPOLATION);
let second = pool.ask(&mut cache, 1000, COMMITTING, NO_INTERPOLATION);
assert_eq!(pool.calls.get(), 1);
assert_eq!(first.map(|h| h.amount_out), Some(BigUint::from(2000u64)));
assert_eq!(second.map(|h| h.amount_out), Some(BigUint::from(2000u64)));
}
#[test]
fn test_swap_short_circuits_above_a_refusal() {
let pool = CountingPool::refusing();
let mut cache = SwapCache::new();
cache.swap(
pool.direction(),
&BigUint::from(500u64),
COMMITTING,
|| Ok(hop(1000, 10)),
NO_INTERPOLATION,
);
pool.ask(&mut cache, 1000, COMMITTING, NO_INTERPOLATION);
let calls_after_refusal = pool.calls.get();
let larger = pool.ask(&mut cache, 5000, COMMITTING, NO_INTERPOLATION);
assert!(larger.is_none());
assert_eq!(pool.calls.get(), calls_after_refusal, "the pool was asked again");
}
#[test]
fn test_swap_interpolates_only_for_a_pass_that_allows_it() {
let interpolating = CountingPool::paying(0);
let mut cache = SwapCache::new();
cache.swap(
interpolating.direction(),
&BigUint::from(1000u64),
RANKING,
|| Ok(hop(1000, 10)),
INTERPOLATES,
);
cache.swap(
interpolating.direction(),
&BigUint::from(1100u64),
RANKING,
|| Ok(hop(1100, 10)),
INTERPOLATES,
);
let calls_before = interpolating.calls.get();
let read_across = interpolating.ask(&mut cache, 1050, RANKING, INTERPOLATES);
assert_eq!(interpolating.calls.get(), calls_before, "ranking should not have called");
assert_eq!(read_across.map(|h| h.amount_out), Some(BigUint::from(1050u64)));
let simulated = interpolating.ask(&mut cache, 1060, EXCHANGE, NO_INTERPOLATION);
assert_eq!(interpolating.calls.get(), calls_before + 1, "exchange must call the pool");
assert_eq!(simulated.map(|h| h.amount_out), Some(BigUint::from(0u64)));
}
#[test]
fn test_swap_does_not_store_an_interpolated_answer() {
let pool = CountingPool::paying(7777);
let mut cache = SwapCache::new();
cache.swap(
pool.direction(),
&BigUint::from(1000u64),
RANKING,
|| Ok(hop(1000, 10)),
INTERPOLATES,
);
cache.swap(
pool.direction(),
&BigUint::from(1100u64),
RANKING,
|| Ok(hop(1100, 10)),
INTERPOLATES,
);
pool.ask(&mut cache, 1050, RANKING, INTERPOLATES);
let asked_again = pool.ask(&mut cache, 1050, EXCHANGE, NO_INTERPOLATION);
assert_eq!(pool.calls.get(), 1, "the interpolated answer should not have been stored");
assert_eq!(asked_again.map(|h| h.amount_out), Some(BigUint::from(7777u64)));
}
#[test]
fn test_failure_that_is_not_a_limit_does_not_reach_upwards() {
let mut swapped = cache_holding(vec![(1000, Some(hop(2000, 50)))]);
record(&mut swapped, 2000, Err(Refusal::Failed));
assert!(!swapped.refuses(&BigUint::from(2000u64)));
assert!(!swapped.refuses(&BigUint::from(5000u64)));
}
#[test]
fn test_lower_refusal_moves_the_refusal_point_down() {
let mut swapped = cache_holding(vec![(1000, Some(hop(2000, 50)))]);
record(&mut swapped, 3000, Err(Refusal::OverLimit));
record(&mut swapped, 2000, Err(Refusal::OverLimit));
assert!(swapped.refuses(&BigUint::from(2000u64)));
}
}