Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/hyperdrive-math/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ impl State {
fn share_adjustment(&self) -> I256 {
self.info.share_adjustment
}

fn lp_total_supply(&self) -> FixedPoint {
self.info.lp_total_supply.into()
}

fn withdrawal_shares_proceeds(&self) -> FixedPoint {
self.info.withdrawal_shares_proceeds.into()
}

fn withdrawal_shares_ready_to_withdraw(&self) -> FixedPoint {
self.info.withdrawal_shares_ready_to_withdraw.into()
}
}

impl YieldSpace for State {
Expand Down
333 changes: 322 additions & 11 deletions crates/hyperdrive-math/src/lp.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,161 @@
use std::cmp::Ordering;

use ethers::types::{I256, U256};
use eyre::{eyre, Result};
use fixed_point::FixedPoint;
use fixed_point_macros::{fixed, int256};

use crate::{State, YieldSpace};
use crate::{calculate_effective_share_reserves, State, YieldSpace};

impl State {
// Calculates the lp_shares for a given contribution when adding liquidity.
pub fn calculate_add_liquidity(
&self,
current_block_timestamp: U256,
contribution: FixedPoint,
min_lp_share_price: FixedPoint,
min_apr: FixedPoint,
max_apr: FixedPoint,
as_base: bool,
) -> Result<FixedPoint> {
// Ensure that the contribution is greater than or equal to the minimum
// transaction amount.
if contribution < self.minimum_transaction_amount() {
return Err(eyre!(
"MinimumTransactionAmount: Contribution is smaller than the minimum transaction."
));
}

// Enforce the slippage guard.
let apr = self.calculate_spot_rate();
if apr < min_apr || apr > max_apr {
return Err(eyre!("InvalidApr: Apr is outside the slippage guard."));
}

// Get lp_total_supply for the lp_shares calculation.
let lp_total_supply = self.lp_total_supply();

// Get the starting_present_value.
let starting_present_value = self.calculate_present_value(current_block_timestamp);

// Get the ending_present_value.
let share_contribution = {
if as_base {
// Attempt a crude conversion from base to shares.
I256::try_from(contribution / self.vault_share_price()).unwrap()
} else {
I256::try_from(contribution).unwrap()
}
};
let new_state = self.get_state_after_liquidity_update(share_contribution);
let ending_present_value = new_state.calculate_present_value(current_block_timestamp);

// Ensure the present value didn't decrease after adding liquidity.
if ending_present_value < starting_present_value {
return Err(eyre!("DecreasedPresentValueWhenAddingLiquidity: Present value decreased after adding liquidity."));
}

// Calculate the lp_shares.
let lp_shares = (ending_present_value - starting_present_value)
.mul_div_down(lp_total_supply, starting_present_value);

// Ensure that enough lp_shares are minted so that they can be redeemed.
if lp_shares < self.minimum_transaction_amount() {
return Err(eyre!(
"MinimumTransactionAmount: Not enough lp shares minted."
));
}

// Enforce the minimum LP share price slippage guard.
if contribution.div_down(lp_shares) < min_lp_share_price {
return Err(eyre!("OutputLimit: Not enough lp shares minted."));
}

Ok(lp_shares)
}

// Gets the resulting state when updating liquidity.
pub fn get_state_after_liquidity_update(&self, share_reserves_delta: I256) -> State {
let share_reserves = self.share_reserves();
let share_adjustment = self.share_adjustment();
let bond_reserves = self.bond_reserves();
let minimum_share_reserves = self.minimum_share_reserves();

// Calculate new reserve and adjustment levels.
let (updated_share_reserves, updated_share_adjustment, updated_bond_reserves) = self
.calculate_update_liquidity(
share_reserves,
share_adjustment,
bond_reserves,
minimum_share_reserves,
share_reserves_delta,
)
.unwrap();

// Update and return the new state.
let mut new_info = self.info.clone();
new_info.share_reserves = U256::from(updated_share_reserves);
new_info.share_adjustment = updated_share_adjustment;
new_info.bond_reserves = U256::from(updated_bond_reserves);
State {
config: self.config.clone(),
info: new_info,
}
}

// Calculates the resulting share_reserves, share_adjustment, and
// bond_reserves when updating liquidity with a share_reserves_delta.
fn calculate_update_liquidity(
&self,
share_reserves: FixedPoint,
share_adjustment: I256,
bond_reserves: FixedPoint,
minimum_share_reserves: FixedPoint,
share_reserves_delta: I256,
) -> Result<(FixedPoint, I256, FixedPoint), &'static str> {
if share_reserves_delta == I256::zero() {
return Ok((share_reserves, share_adjustment, bond_reserves));
}

// Get the updated share reserves.
let new_share_reserves = if share_reserves_delta > I256::zero() {
I256::try_from(share_reserves).unwrap() + share_reserves_delta
} else {
I256::try_from(share_reserves).unwrap() - share_reserves_delta
};

// Ensure the minimum share reserve level.
if new_share_reserves < I256::try_from(minimum_share_reserves).unwrap() {
return Err("Update would result in share reserves below minimum.");
}

// Convert to Fixedpoint to allow the math below.
let new_share_reserves = FixedPoint::from(new_share_reserves);

// Get the updated share adjustment.
let new_share_adjustment = if share_adjustment >= I256::zero() {
let share_adjustment_fp = FixedPoint::from(share_adjustment);
I256::try_from(new_share_reserves.mul_div_down(share_adjustment_fp, share_reserves))
.unwrap()
} else {
let share_adjustment_fp = FixedPoint::from(-share_adjustment);
-I256::try_from(new_share_reserves.mul_div_up(share_adjustment_fp, share_reserves))
.unwrap()
};

// Get the updated bond reserves.
let old_effective_share_reserves = calculate_effective_share_reserves(
self.effective_share_reserves(),
self.share_adjustment(),
);
let new_effective_share_reserves =
calculate_effective_share_reserves(new_share_reserves, new_share_adjustment);
let new_bond_reserves =
bond_reserves.mul_div_down(new_effective_share_reserves, old_effective_share_reserves);

Ok((new_share_reserves, new_share_adjustment, new_bond_reserves))
}

/// Calculates the number of base that are not reserved by open positions.
pub fn calculate_idle_share_reserves_in_base(&self) -> FixedPoint {
// NOTE: Round up to underestimate the pool's idle.
Expand Down Expand Up @@ -87,10 +236,8 @@ impl State {
let max_curve_trade = self
.calculate_max_sell_bonds_in_safe(self.minimum_share_reserves())
.unwrap();
if max_curve_trade >= net_curve_position.into() {
match self
.calculate_shares_out_given_bonds_in_down_safe(net_curve_position.into())
{
if max_curve_trade >= net_curve_position {
match self.calculate_shares_out_given_bonds_in_down_safe(net_curve_position) {
Ok(net_curve_trade) => -I256::try_from(net_curve_trade).unwrap(),
Err(err) => {
// If the net curve position is smaller than the
Expand Down Expand Up @@ -130,9 +277,7 @@ impl State {
let net_curve_position: FixedPoint = FixedPoint::from(-net_curve_position);
let max_curve_trade = self.calculate_max_buy_bonds_out_safe().unwrap();
if max_curve_trade >= net_curve_position {
match self
.calculate_shares_in_given_bonds_out_up_safe(net_curve_position.into())
{
match self.calculate_shares_in_given_bonds_out_up_safe(net_curve_position) {
Ok(net_curve_trade) => I256::try_from(net_curve_trade).unwrap(),
Err(err) => {
// If the net curve position is smaller than the
Expand Down Expand Up @@ -189,15 +334,181 @@ impl State {

#[cfg(test)]
mod tests {
use std::panic;
use std::{
panic,
panic::{catch_unwind, AssertUnwindSafe},
};

use eyre::Result;
use hyperdrive_wrappers::wrappers::mock_lp_math::PresentValueParams;
use rand::{thread_rng, Rng};
use test_utils::{chain::TestChainWithMocks, constants::FAST_FUZZ_RUNS};
use test_utils::{
agent::Agent,
chain::{Chain, TestChain, TestChainWithMocks},
constants::{FAST_FUZZ_RUNS, FUZZ_RUNS},
};

use super::*;

#[tokio::test]
async fn fuzz_test_calculate_add_liquidity_unhappy_with_random_state() -> Result<()> {
// Get the State from solidity before adding liquidity.
let mut rng = thread_rng();

for _ in 0..*FAST_FUZZ_RUNS {
let state = rng.gen::<State>();
let contribution = rng.gen_range(fixed!(0)..=state.bond_reserves());
let current_block_timestamp = U256::from(rng.gen_range(0..=60 * 60 * 24 * 365));
let min_lp_share_price = rng.gen_range(fixed!(0)..=fixed!(10e18));
let min_apr = rng.gen_range(fixed!(0)..fixed!(1e18));
let max_apr = rng.gen_range(fixed!(5e17)..fixed!(1e18));

// Calculate lp_shares from the rust function.
let result = catch_unwind(AssertUnwindSafe(|| {
state.calculate_add_liquidity(
current_block_timestamp,
contribution,
min_lp_share_price,
min_apr,
max_apr,
true,
)
}));

// Testing mostly unhappy paths here since random state will mostly fail.
match result {
Ok(result) => match result {
Ok(lp_shares) => {
assert!(lp_shares >= min_lp_share_price);
}
Err(err) => {
let message = err.to_string();

if message == "MinimumTransactionAmount: Contribution is smaller than the minimum transaction." {
assert!(contribution < state.minimum_transaction_amount());
}

else if message == "InvalidApr: Apr is outside the slippage guard." {
let apr = state.calculate_spot_rate();
assert!(apr < min_apr || apr > max_apr);
}

else if message == "DecreasedPresentValueWhenAddingLiquidity: Present value decreased after adding liquidity." {
let share_contribution =
I256::try_from(contribution / state.vault_share_price()).unwrap();
let new_state = state.get_state_after_liquidity_update(share_contribution);
let starting_present_value = state.calculate_present_value(current_block_timestamp);
let ending_present_value = new_state.calculate_present_value(current_block_timestamp);
assert!(ending_present_value < starting_present_value);
}

else if message == "MinimumTransactionAmount: Not enough lp shares minted." {
let share_contribution =
I256::try_from(contribution / state.vault_share_price()).unwrap();
let new_state = state.get_state_after_liquidity_update(share_contribution);
let starting_present_value = state.calculate_present_value(current_block_timestamp);
let ending_present_value = new_state.calculate_present_value(current_block_timestamp);
let lp_shares = (ending_present_value - starting_present_value)
.mul_div_down(state.lp_total_supply(), starting_present_value);
assert!(lp_shares < state.minimum_transaction_amount());
}

else if message == "OutputLimit: Not enough lp shares minted." {
let share_contribution =
I256::try_from(contribution / state.vault_share_price()).unwrap();
let new_state = state.get_state_after_liquidity_update(share_contribution);
let starting_present_value = state.calculate_present_value(current_block_timestamp);
let ending_present_value = new_state.calculate_present_value(current_block_timestamp);
let lp_shares = (ending_present_value - starting_present_value)
.mul_div_down(state.lp_total_supply(), starting_present_value);
assert!(contribution.div_down(lp_shares) < min_lp_share_price);
}
}
},
// ignore inner panics
Err(_) => {}
}
}

Ok(())
}
#[tokio::test]
async fn fuzz_test_calculate_add_liquidity() -> Result<()> {
// Spawn a test chain and create two agents -- Alice and Bob.
let mut rng = thread_rng();
let chain = TestChain::new(2).await?;
let (alice, bob) = (chain.accounts()[0].clone(), chain.accounts()[1].clone());
let mut alice =
Agent::new(chain.client(alice).await?, chain.addresses().clone(), None).await?;
let mut bob = Agent::new(chain.client(bob).await?, chain.addresses(), None).await?;
let config = bob.get_config().clone();

// Test happy paths.
for _ in 0..*FUZZ_RUNS {
// Snapshot the chain.
let id = chain.snapshot().await?;

// Fund Alice and Bob.
let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18));
let contribution = rng.gen_range(fixed!(10_000e18)..=fixed!(500_000_000e18));
let budget = rng.gen_range(fixed!(10e18)..=fixed!(500_000_000e18));
alice.fund(contribution).await?;
bob.fund(budget).await?;

// Alice initializes the pool.
alice.initialize(fixed_rate, contribution, None).await?;

// Some of the checkpoint passes and variable interest accrues.
alice
.checkpoint(alice.latest_checkpoint().await?, None)
.await?;
let rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18));
alice
.advance_time(
rate,
FixedPoint::from(config.checkpoint_duration) * fixed!(0.5e18),
)
.await?;

// Get the State from solidity before adding liquidity.
let hd_state = bob.get_state().await?;
let state = State {
config: hd_state.config.clone(),
info: hd_state.info.clone(),
};

// Bob adds liquidity
bob.add_liquidity(budget, None).await?;
let lp_shares_mock = bob.lp_shares();

// Calculate lp_shares from the rust function.
let lp_shares = state
.calculate_add_liquidity(
bob.now().await?,
budget,
fixed!(0),
fixed!(0),
FixedPoint::from(U256::MAX),
true,
)
.unwrap();

// Rust can't account for slippage.
assert!(lp_shares >= lp_shares_mock, "Should over estimate.");
// Answer should still be mostly the same.
assert!(
fixed!(1e18) - lp_shares_mock / lp_shares < fixed!(1e11),
"Difference should be less than 0.0000001."
);

// Revert to the snapshot and reset the agent's wallets.
chain.revert(id).await?;
alice.reset(Default::default());
bob.reset(Default::default());
}

Ok(())
}

#[tokio::test]
async fn fuzz_calculate_present_value() -> Result<()> {
let chain = TestChainWithMocks::new(1).await?;
Expand Down
2 changes: 1 addition & 1 deletion crates/hyperdrive-math/src/yield_space.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ pub trait YieldSpace {
// fall below the minimum share reserves. Otherwise, the minimum share
// reserves is just zMin.
if self.zeta() < I256::zero() {
z_min = z_min + FixedPoint::from(-self.zeta());
z_min += FixedPoint::from(-self.zeta());
}

// We solve for the maximum sell using the constraint that the pool's
Expand Down