hylo-core 0.5.0

Core protocol data types, math, and utilities.
Documentation
use anchor_lang::prelude::*;
use anchor_spl::token::Mint;
use fix::prelude::*;

use crate::error::CoreError::ExoAmountNormalization;

/// Bridges runtime mint decimals to typed `UFix64<N9>`.
///
/// # Errors
/// * Unsupported decimal count or conversion overflow
pub fn normalize_mint_exp(mint: &Mint, amount: u64) -> Result<UFix64<N9>> {
  match mint.decimals {
    2 => UFix64::<N2>::new(amount).checked_convert(),
    3 => UFix64::<N3>::new(amount).checked_convert(),
    4 => UFix64::<N4>::new(amount).checked_convert(),
    5 => UFix64::<N5>::new(amount).checked_convert(),
    6 => UFix64::<N6>::new(amount).checked_convert(),
    7 => UFix64::<N7>::new(amount).checked_convert(),
    8 => UFix64::<N8>::new(amount).checked_convert(),
    9 => Some(UFix64::<N9>::new(amount)),
    10 => UFix64::<N10>::new(amount).checked_convert(),
    _ => None,
  }
  .ok_or(ExoAmountNormalization.into())
}

/// Converts typed `UFix64<N9>` back to a raw `u64` in the mint's native
/// decimals.
///
/// # Errors
/// * Unsupported decimal count
pub fn denormalize_mint_exp(mint: &Mint, amount: UFix64<N9>) -> Result<u64> {
  match mint.decimals {
    2 => amount.checked_convert::<N2>().map(|o| o.bits),
    3 => amount.checked_convert::<N3>().map(|o| o.bits),
    4 => amount.checked_convert::<N4>().map(|o| o.bits),
    5 => amount.checked_convert::<N5>().map(|o| o.bits),
    6 => amount.checked_convert::<N6>().map(|o| o.bits),
    7 => amount.checked_convert::<N7>().map(|o| o.bits),
    8 => amount.checked_convert::<N8>().map(|o| o.bits),
    9 => Some(amount.bits),
    10 => amount.checked_convert::<N10>().map(|o| o.bits),
    _ => None,
  }
  .ok_or(ExoAmountNormalization.into())
}

#[macro_export]
macro_rules! eq_tolerance {
  ($l:expr, $r:expr, $place:ty, $tol:expr) => {{
    let diff = $l.convert::<$place>().abs_diff(&$r.convert::<$place>());
    diff <= $tol
  }};
}

#[cfg(test)]
pub mod proptest {
  use fix::prelude::*;
  use fix::typenum::{N2, N6, N9};
  use proptest::prelude::*;

  use crate::exchange_math::collateral_ratio;

  /// Represents a possible state of the protocol, collateral, and tokens.
  /// Always holds the Hylo invariant: `ns * ps = nx * px + nh * ph`.
  #[derive(Debug)]
  pub struct ProtocolState {
    pub usd_sol_price: UFix64<N9>,
    pub stablecoin_amount: UFix64<N6>,
    pub stablecoin_nav: UFix64<N9>,
    pub levercoin_amount: UFix64<N6>,
    pub levercoin_nav: UFix64<N9>,
  }

  impl ProtocolState {
    #[must_use]
    pub fn total_sol(&self) -> Option<UFix64<N9>> {
      let stablecoin_cap = self
        .stablecoin_amount
        .mul_div_floor(self.stablecoin_nav, UFix64::one())?;
      let levercoin_cap = self
        .levercoin_amount
        .mul_div_floor(self.levercoin_nav, UFix64::one())?;
      let tvl = stablecoin_cap.checked_add(&levercoin_cap)?;
      tvl
        .convert()
        .mul_div_floor(UFix64::one(), self.usd_sol_price)
    }

    #[must_use]
    pub fn collateral_ratio(&self) -> Option<UFix64<N9>> {
      collateral_ratio(
        self.total_sol()?,
        self.usd_sol_price,
        self.stablecoin_amount,
      )
      .ok()
    }

    #[must_use]
    pub fn next_target_collateral_ratio(&self) -> Option<UFix64<N2>> {
      let current = self.collateral_ratio()?.convert::<N2>();
      let one = UFix64::<N2>::new(100);
      let next = current.mul_div_ceil(UFix64::new(90), one)?;
      if next < one {
        None
      } else {
        Some(next)
      }
    }
  }

  prop_compose! {
    /// Fixed stablecoin NAV at $1 only.
    pub fn protocol_state(_: ())
      (usd_sol_price in usd_sol_price(),
      stablecoin_amount in token_amount(),
      levercoin_amount in token_amount(),
      levercoin_nav in levercoin_nav()) -> ProtocolState {
       ProtocolState {
         usd_sol_price,
         stablecoin_amount,
         stablecoin_nav: UFix64::one(),
         levercoin_amount,
         levercoin_nav,
       }
    }
  }

  prop_compose! {
    /// Makes it possible to have depegged stablecoin NAV in the state.
    pub fn protocol_state_depeg(_: ())
      (usd_sol_price in usd_sol_price(),
      stablecoin_amount in token_amount(),
      stablecoin_nav in stablecoin_nav(),
      levercoin_amount in token_amount(),
      levercoin_nav in levercoin_nav()) -> ProtocolState {
       ProtocolState {
         usd_sol_price,
         stablecoin_amount,
         stablecoin_nav,
         levercoin_amount,
         levercoin_nav,
       }
    }
  }

  pub fn usd_sol_price() -> BoxedStrategy<UFix64<N9>> {
    (10_000_000_000u64..2_500_000_000_000u64)
      .prop_map(UFix64::new)
      .boxed()
  }

  pub fn token_amount() -> BoxedStrategy<UFix64<N6>> {
    (1_0000u64..5_000_000_000_000u64)
      .prop_map(UFix64::new)
      .boxed()
  }

  pub fn stablecoin_nav() -> BoxedStrategy<UFix64<N9>> {
    (800_000_000u64..1_000_000_000u64)
      .prop_map(UFix64::new)
      .boxed()
  }

  pub fn levercoin_nav() -> BoxedStrategy<UFix64<N9>> {
    (100_000u64..1_000_000_000_000u64)
      .prop_map(UFix64::new)
      .boxed()
  }

  /// Realistic LST/SOL price between 1.0 and 5.0.
  pub fn lst_sol_price() -> BoxedStrategy<UFix64<N9>> {
    (1_000_000_000u64..5_000_000_000u64)
      .prop_map(UFix64::new)
      .boxed()
  }

  /// Extreme LST/SOL price including depeg.
  pub fn lst_sol_price_extreme() -> BoxedStrategy<UFix64<N9>> {
    (1u64..u64::MAX / 2).prop_map(UFix64::new).boxed()
  }

  /// Realistic LST amount: dust to 100K tokens.
  /// Upper bound prevents overflow in conversions with high prices.
  pub fn lst_amount() -> BoxedStrategy<UFix64<N9>> {
    (1_000u64..100_000_000_000_000u64)
      .prop_map(UFix64::new)
      .boxed()
  }

  /// Extreme LST amount from 1 unit to half of max.
  pub fn lst_amount_extreme() -> BoxedStrategy<UFix64<N9>> {
    (1u64..u64::MAX / 2).prop_map(UFix64::new).boxed()
  }
}

#[cfg(test)]
mod tests {
  use fix::aliases::si::{Micro, Nano};
  use fix::prelude::*;

  use crate::error::CoreError::SlippageExceeded;
  use crate::slippage_config::SlippageConfig;

  #[test]
  fn one_nano() {
    let one = Nano::<u64>::one();
    assert_eq!("1000000000x10^-9", format!("{one:?}"));
  }

  #[test]
  fn precision6_unit() {
    let one = Micro::<u64>::one();
    let unit = one * one;
    assert_eq!(one, unit.convert());
  }

  #[test]
  fn precision6_overflow_guard() {
    let max = Micro::new(u128::MAX);
    assert!(max.checked_mul(&max).is_none());
  }

  #[test]
  fn neg_sub_underflows() {
    let last = Nano::new(169_120_000_u64);
    let now = Nano::new(151_444_800_u64);
    let sub = now.checked_sub(&last);
    assert!(sub.is_none());
  }

  #[test]
  fn slippage_neg() {
    let config =
      SlippageConfig::new(UFix64::<N6>::new(1_201_346), UFix64::new(20));
    let amount = UFix64::<N6>::new(1_198_942);
    let out = config.validate_token_out(amount);
    assert_eq!(out, Err(SlippageExceeded.into()));
  }

  #[test]
  fn slippage_pos() {
    let config =
      SlippageConfig::new(UFix64::<N6>::new(99_411_501), UFix64::new(10));
    let amount = UFix64::<N6>::new(99_312_089);
    let out = config.validate_token_out(amount);
    assert!(out.is_ok());
  }
}