diff --git a/crates/hyperdrive-math/src/short/max.rs b/crates/hyperdrive-math/src/short/max.rs index d332cc4d..8946fddc 100644 --- a/crates/hyperdrive-math/src/short/max.rs +++ b/crates/hyperdrive-math/src/short/max.rs @@ -91,10 +91,10 @@ impl State { /// deposit amount. /// /// If the result is Ok then the answer is guaranteed to be within - /// `maybe_base_tolerance` of the target base amount (default is 1e9). + /// `maybe_base_tolerance` of the target base amount (default is 1e10). /// /// Increasing `maybe_max_iterations` will increase the accuracy of the - /// result (default is 500). + /// result (default is 1_000). pub fn calculate_short_bonds_given_deposit( &self, target_base_amount: FixedPoint, @@ -103,8 +103,8 @@ impl State { maybe_base_tolerance: Option>, maybe_max_iterations: Option, ) -> Result> { - let base_tolerance = maybe_base_tolerance.unwrap_or(fixed!(1e9)); - let max_iterations = maybe_max_iterations.unwrap_or(500); + let base_tolerance = maybe_base_tolerance.unwrap_or(fixed!(1e10)); + let max_iterations = maybe_max_iterations.unwrap_or(1_000); // The max bond amount might be below the pool's minimum. // If so, no short can be opened. @@ -890,17 +890,13 @@ mod tests { chain::TestChain, constants::{FAST_FUZZ_RUNS, FUZZ_RUNS, SLOW_FUZZ_RUNS}, }; - use hyperdrive_wrappers::wrappers::{ - ihyperdrive::{Checkpoint, Options}, - mock_hyperdrive_math::MaxTradeParams, - }; + use hyperdrive_wrappers::wrappers::ihyperdrive::Checkpoint; use rand::{thread_rng, Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; use super::*; use crate::test_utils::{ - agent::HyperdriveMathAgent, - preamble::{get_max_short, initialize_pool_with_random_state}, + agent::HyperdriveMathAgent, preamble::initialize_pool_with_random_state, }; #[tokio::test] @@ -960,6 +956,8 @@ mod tests { let empirical_derivative_epsilon = fixed!(1e14); let test_tolerance = fixed!(1e14); let mut rng = thread_rng(); + + // Run the fuzz tests. for _ in 0..*FAST_FUZZ_RUNS { let state = rng.gen::(); // Min trade amount should be at least 1,000x the derivative epsilon @@ -1020,103 +1018,17 @@ mod tests { Ok(()) } - #[tokio::test] - async fn fuzz_calculate_short_bonds_given_deposit() -> Result<()> { - let test_tolerance = fixed!(1e9); - let max_iterations = 500; - let mut rng = thread_rng(); - for _ in 0..*FUZZ_RUNS { - let state = rng.gen::(); - // TODO: Absolute max doesn't always work. Sometimes the random - // state causes an overflow when calculating absolute max. - // Unlikely: fix the state generator so that the random state always has a valid max. - // Likely: fix absolute max short such that the output is guaranteed to be solvent. - match panic::catch_unwind(|| { - state.calculate_absolute_max_short(Some(test_tolerance), Some(max_iterations)) - }) { - Ok(max_bond_no_panic) => { - match max_bond_no_panic { - Ok(absolute_max_bond_amount) => { - // Get random input parameters. - let open_vault_share_price = - rng.gen_range(fixed!(1e5)..=state.vault_share_price()); - match panic::catch_unwind(|| { - state.calculate_open_short( - absolute_max_bond_amount, - open_vault_share_price, - ) - }) { - Ok(result_no_panic) => match result_no_panic { - Ok(_) => (), - Err(_) => continue, - }, - Err(_) => continue, - } - let max_short_bonds = match get_max_short(state.clone(), None) { - Ok(max_short_trade) => { - max_short_trade.min(absolute_max_bond_amount) - } - Err(_) => continue, - }; - let max_short_base = state - .calculate_open_short(max_short_bonds, open_vault_share_price)?; - let target_base_amount = - rng.gen_range(state.minimum_transaction_amount()..=max_short_base); - // Run the function to be tested. - let bond_amount = state.calculate_short_bonds_given_deposit( - target_base_amount, - open_vault_share_price, - absolute_max_bond_amount, - Some(test_tolerance), - Some(max_iterations), - )?; - // Verify outputs. - let computed_base_amount = - state.calculate_open_short(bond_amount, open_vault_share_price)?; - assert!( - target_base_amount >= computed_base_amount, - "target is less than computed base amount: - target_base_amount ={:#?} - computed_base_amount={:#?}", - target_base_amount, - computed_base_amount - ); - assert!( - target_base_amount - computed_base_amount <= test_tolerance, - "target - computed base amounts are greater than tolerance: - error = {:#?} - tolerance = {:#?}", - target_base_amount - computed_base_amount, - test_tolerance - ); - } - Err(_) => continue, // absolute max threw an error - } - } - Err(_) => continue, // absolute max threw a panic - } - } - Ok(()) - } - /// Test to ensure that the absolute max short guess is always solvent. #[tokio::test] async fn fuzz_calculate_absolute_max_short_guess() -> Result<()> { let solvency_tolerance = fixed!(100_000_000e18); let mut rng = thread_rng(); + + // Run the fuzz tests. for _ in 0..*FAST_FUZZ_RUNS { // Compute a random state and checkpoint exposure. let state = rng.gen::(); // Check that a short is possible. - if state - .effective_share_reserves()? - .min(state.share_reserves()) - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - continue; - } match state.solvency_after_short(state.minimum_transaction_amount()) { Ok(_) => (), Err(_) => continue, @@ -1124,6 +1036,18 @@ mod tests { // Compute the guess, check that it is solvent. let max_short_guess = state.absolute_max_short_guess()?; + assert!( + state.solvency_after_short(max_short_guess).is_ok(), + "max_short_guess={:#?} is not solvent", + max_short_guess + ); + assert!( + state + .calculate_open_short(max_short_guess, state.vault_share_price()) + .is_ok(), + "cannot open short with max_short_guess={:#?}", + max_short_guess + ); let solvency = state.solvency_after_short(max_short_guess)?; // Check that the remaining available shares in the pool are below a @@ -1135,7 +1059,6 @@ mod tests { solvency_tolerance ); } - Ok(()) } @@ -1146,20 +1069,12 @@ mod tests { async fn fuzz_calculate_absolute_max_short() -> Result<()> { let bonds_tolerance = fixed_u256!(1e9); let max_iterations = 500; - // Run the fuzz tests + + // Run the fuzz tests. let mut rng = thread_rng(); for _ in 0..*FUZZ_RUNS { let state = rng.gen::(); // Make sure a short is possible. - if state - .effective_share_reserves()? - .min(state.share_reserves()) - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - continue; - } match state.solvency_after_short(state.minimum_transaction_amount()) { Ok(_) => (), Err(_) => continue, @@ -1172,11 +1087,19 @@ mod tests { // The short should be valid. assert!(absolute_max_short >= state.minimum_transaction_amount()); assert!(state.solvency_after_short(absolute_max_short).is_ok()); - + assert!(state + .calculate_open_short(absolute_max_short, state.vault_share_price()) + .is_ok()); // Adding tolerance more bonds should be insolvent. assert!(state .solvency_after_short(absolute_max_short + bonds_tolerance) .is_err()); + assert!(state + .calculate_open_short( + absolute_max_short + bonds_tolerance, + state.vault_share_price() + ) + .is_err()); } Ok(()) } @@ -1186,22 +1109,14 @@ mod tests { #[tokio::test] async fn fuzz_open_short_inversion() -> Result<()> { let abs_max_bonds_tolerance = fixed_u256!(1e9); - let budget_base_tolerance = fixed_u256!(1e9); - let max_iterations = 500; - // Run the fuzz tests + let budget_base_tolerance = fixed_u256!(1e10); + let max_iterations = 1_000; let mut rng = thread_rng(); - for _ in 0..*FUZZ_RUNS { + + // Run the fuzz tests. + for _ in 0..*SLOW_FUZZ_RUNS { let state = rng.gen::(); // Make sure a short is possible. - if state - .effective_share_reserves()? - .min(state.share_reserves()) - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - continue; - } match state.solvency_after_short(state.minimum_transaction_amount()) { Ok(_) => (), Err(_) => continue, @@ -1281,49 +1196,46 @@ mod tests { Ok(()) } + /// This test ensures that `calculate_short_bonds_given_deposit` returns a + /// short bond amount that results consuming the agent's budget, ignoring + /// the slippage guard. #[tokio::test] async fn fuzz_calculate_max_short_budget_consumed() -> Result<()> { - // TODO: This should be fixed!(0.0001e18) == 0.01% - let budget_tolerance = fixed!(1e18); - - // Spawn a test chain and create two agents -- Alice and Bob. Alice - // is funded with a large amount of capital so that she can initialize - // the pool. Bob is funded with a small amount of capital so that we - // can test `calculate_max_short` when budget is the primary constraint. - let mut rng = thread_rng(); - - // Initialize the chain and the agents. + let abs_max_bond_tolerance = fixed!(1e9); + let remaining_balance_tolerance = fixed!(1e9); + // Set up a random number generator. We use ChaCha8Rng with a randomly + // generated seed, which makes it easy to reproduce test failures given + // the seed. + let mut rng = { + let mut rng = thread_rng(); + let seed = rng.gen(); + ChaCha8Rng::seed_from_u64(seed) + }; + // Initialize the test chain. let chain = TestChain::new().await?; let mut alice = chain.alice().await?; let mut bob = chain.bob().await?; - let config = alice.get_config().clone(); + let mut celine = chain.celine().await?; - for _ in 0..*FUZZ_RUNS { + // Run the fuzz tests. + let mut num_tests = 0; + for _ in 0..*SLOW_FUZZ_RUNS { // Snapshot the chain. let id = chain.snapshot().await?; - - // Fund Alice and Bob. - let contribution = rng.gen_range(fixed!(100_000e18)..=fixed!(100_000_000e18)); - alice.fund(contribution).await?; - - // Alice initializes the pool. - let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18)); - alice.initialize(fixed_rate, contribution, None).await?; - - // Some of the checkpoint passes and variable interest accrues. - alice - .checkpoint(alice.latest_checkpoint().await?, uint256!(0), None) - .await?; - let variable_rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18)); - alice - .advance_time( - variable_rate, - FixedPoint::from(config.checkpoint_duration) * fixed!(0.5e18), - ) - .await?; - + // Run the preamble. + initialize_pool_with_random_state(&mut rng, &mut alice, &mut bob, &mut celine).await?; // Get the current state of the pool. let state = alice.get_state().await?; + // Check that a short is possible. + if state + .solvency_after_short(state.minimum_transaction_amount()) + .is_err() + { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + celine.reset(Default::default()).await?; + continue; + } let Checkpoint { vault_share_price: open_vault_share_price, weighted_spot_price: _, @@ -1331,66 +1243,65 @@ mod tests { } = alice .get_checkpoint(state.to_checkpoint(alice.now().await?)) .await?; - // Check that a short is possible. - if state.effective_share_reserves()? - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - chain.revert(id).await?; - alice.reset(Default::default()).await?; - // Don't need to reset bob because he hasn't done anything. - continue; - } - let global_max_short_bonds = state.calculate_absolute_max_short(None, None)?; - // Bob should always be budget constrained when trying to open the short. + // Celine should always be budget constrained when trying to open the short. + let global_max_short_bonds = + state.calculate_absolute_max_short(Some(abs_max_bond_tolerance), None)?; let global_max_base_required = state .calculate_open_short(global_max_short_bonds, open_vault_share_price.into())?; + if global_max_base_required - fixed!(1e18) <= state.minimum_transaction_amount() { + continue; // Avoid case where max is within 1e18 of min. + } let budget = rng.gen_range( state.minimum_transaction_amount()..=global_max_base_required - fixed!(1e18), ); - bob.fund(budget).await?; - - // Bob opens a max short position. We allow for a very small amount - // of slippage to account for interest accrual between the time the - // calculation is performed and the transaction is submitted. - let slippage_tolerance = fixed!(0.0001e18); // 0.01% - let max_short_bonds = bob.calculate_max_short(Some(slippage_tolerance)).await?; - bob.open_short(max_short_bonds, None, None).await?; - - // Bob used a slippage tolerance of 0.01%, which means - // that the max short is always consuming at least 99.99% of - // the budget. - let max_allowable_balance = - budget * (fixed!(1e18) - slippage_tolerance) * budget_tolerance; - let remaining_balance = bob.base(); - assert!(remaining_balance < max_allowable_balance, - "expected {}% of budget consumed, or remaining_balance={} < max_allowable_balance={} - global_max_short_bonds = {}; max_short_bonds = {}; global_max_base_required={}", - format!("{}", fixed!(100e18)*(fixed!(1e18) - budget_tolerance)).trim_end_matches("0"), + celine.fund(budget).await?; + + // Celine opens a max short position. + let slippage_tolerance = fixed!(0.01e18); + let max_short_bonds = celine.calculate_max_short(Some(slippage_tolerance)).await?; + celine + .open_short(max_short_bonds, Some(slippage_tolerance), None) + .await?; + + // The max short should consume the budget up to the slippage + // tolerance. + let remaining_balance = celine.base(); + assert!( + remaining_balance <= remaining_balance_tolerance + budget * slippage_tolerance, + "expected remaining_balance={:#?} <= remaining_balance_tolerance={:#?} + global_max_short_bonds={:#?} + global_max_base_required={:#?} + budget={:#?} + max_short_bonds={:#?}", remaining_balance, - max_allowable_balance, + format!( + "{}", + remaining_balance_tolerance + budget * slippage_tolerance + ), global_max_short_bonds, - max_short_bonds, global_max_base_required, + budget, + max_short_bonds, ); // Revert to the snapshot and reset the agents' wallets. + num_tests += 1; chain.revert(id).await?; alice.reset(Default::default()).await?; - bob.reset(Default::default()).await?; + celine.reset(Default::default()).await?; } - + // Assert that we've run at least 50% of the tests. + assert!(num_tests > 50); Ok(()) } + /// Test to ensure that the rust computed absolute max short can always be + /// opened in the smart contracts. #[tokio::test] - async fn fuzz_sol_calculate_max_short_without_budget_then_open_short() -> Result<()> { - let max_bonds_tolerance = fixed!(1e10); - let max_base_tolerance = fixed!(1e10); - let reserves_drained_tolerance = fixed!(1e27); - + async fn fuzz_absolute_max_short_valid() -> Result<()> { + let bond_tolerance = fixed!(1e9); + let max_iterations = 500; // Set up a random number generator. We use ChaCha8Rng with a randomly // generated seed, which makes it easy to reproduce test failures given // the seed. @@ -1399,187 +1310,159 @@ mod tests { let seed = rng.gen(); ChaCha8Rng::seed_from_u64(seed) }; - // Initialize the test chain. let chain = TestChain::new().await?; let mut alice = chain.alice().await?; let mut bob = chain.bob().await?; let mut celine = chain.celine().await?; + // Run the fuzz tests. + let mut num_tests = 0; for _ in 0..*SLOW_FUZZ_RUNS { // Snapshot the chain. let id = chain.snapshot().await?; - // Run the preamble. initialize_pool_with_random_state(&mut rng, &mut alice, &mut bob, &mut celine).await?; - - // Get the current state from solidity. - let mut state = alice.get_state().await?; - - // Get the current checkpoint exposure. - let checkpoint_exposure = alice - .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?)) - .await?; - - // Get the global max short from Solidity. - let max_iterations = 7; - match chain - .mock_hyperdrive_math() - .calculate_max_short( - MaxTradeParams { - share_reserves: state.info.share_reserves, - bond_reserves: state.info.bond_reserves, - longs_outstanding: state.info.longs_outstanding, - long_exposure: state.info.long_exposure, - share_adjustment: state.info.share_adjustment, - time_stretch: state.config.time_stretch, - vault_share_price: state.info.vault_share_price, - initial_vault_share_price: state.config.initial_vault_share_price, - minimum_share_reserves: state.config.minimum_share_reserves, - curve_fee: state.config.fees.curve, - flat_fee: state.config.fees.flat, - governance_lp_fee: state.config.fees.governance_lp, - }, - checkpoint_exposure, - max_iterations.into(), - ) - .call() - .await + // Get the current state of the pool. + let state = alice.get_state().await?; + // Check that a short is possible. + if state + .solvency_after_short(state.minimum_transaction_amount()) + .is_err() { - Ok(sol_max_bonds) => { - // Check that a short is possible. - // TODO: We will remove this check in the future; this a failure case in rust that is not - // checked in solidity. - if state.effective_share_reserves()? - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - chain.revert(id).await?; - alice.reset(Default::default()).await?; - bob.reset(Default::default()).await?; - celine.reset(Default::default()).await?; - continue; - } + chain.revert(id).await?; + alice.reset(Default::default()).await?; + celine.reset(Default::default()).await?; + continue; + } - // Solidity reports everything is good, so we run the Rust fns. - let rust_max_bonds = panic::catch_unwind(|| { - state.calculate_absolute_max_short(None, Some(max_iterations)) - }); + // Get the max short. + let global_max_short_bonds = + state.calculate_absolute_max_short(Some(bond_tolerance), Some(max_iterations))?; + // Celine opens a max short position. + // Test passes if this does not revert or throw any errors. + let slippage_tolerance = fixed!(0.01e18); + celine.fund(global_max_short_bonds * fixed!(100e18)).await?; // plenty of money + celine + .open_short(global_max_short_bonds, Some(slippage_tolerance), None) + .await?; - // Compare the max bond amounts. - let rust_max_bonds_unwrapped = rust_max_bonds.unwrap().unwrap(); - let sol_max_bonds_fp = FixedPoint::from(sol_max_bonds); - let error = if rust_max_bonds_unwrapped > sol_max_bonds_fp { - rust_max_bonds_unwrapped - sol_max_bonds_fp - } else { - sol_max_bonds_fp - rust_max_bonds_unwrapped - }; - assert!( - error < max_bonds_tolerance, - "expected abs(rust_bonds - sol_bonds)={} >= max_bonds_tolerance={}", - error, - max_bonds_tolerance - ); - - // The amount Celine has to pay will always be less than the bond amount. - celine.fund(sol_max_bonds.into()).await?; - match celine - .hyperdrive() - .open_short( - sol_max_bonds.into(), - FixedPoint::from(U256::MAX).into(), - fixed!(0).into(), - Options { - destination: celine.address(), - as_base: true, - extra_data: [].into(), - }, - ) - .call() - .await - { - Ok((_, sol_max_base)) => { - // Calling any Solidity Hyperdrive transaction causes the - // mock yield source to accrue some interest. We want to use - // the state before the Solidity OpenShort, but with the - // vault share price after the block tick. - // Get the current vault share price & update state. - let vault_share_price = alice.get_state().await?.vault_share_price(); - state.info.vault_share_price = vault_share_price.into(); - - // Get the open vault share price. - let Checkpoint { - weighted_spot_price: _, - last_weighted_spot_price_update_time: _, - vault_share_price: open_vault_share_price, - } = alice - .get_checkpoint(state.to_checkpoint(alice.now().await?)) - .await?; - - // Compare the open short call outputs. - let rust_max_base = state.calculate_open_short( - rust_max_bonds_unwrapped, - open_vault_share_price.into(), - ); - - let rust_max_base_unwrapped = rust_max_base.unwrap(); - let sol_max_base_fp = FixedPoint::from(sol_max_base); - let error = if rust_max_base_unwrapped > sol_max_base_fp { - rust_max_base_unwrapped - sol_max_base_fp - } else { - sol_max_base_fp - rust_max_base_unwrapped - }; - assert!( - error < max_base_tolerance, - "expected abs(rust_base - sol_base)={} >= max_base_tolerance={}", - error, - max_base_tolerance - ); - - // Make sure the pool was drained. - let pool_shares = state - .effective_share_reserves()? - .min(state.share_reserves()); - let min_share_reserves = state.minimum_share_reserves(); - assert!(pool_shares >= min_share_reserves, - "effective_share_reserves={} should always be greater than the minimum_share_reserves={}.", - state.effective_share_reserves()?, - min_share_reserves, - ); - let reserve_amount_above_minimum = pool_shares - min_share_reserves; - assert!(reserve_amount_above_minimum < reserves_drained_tolerance, - "share_reserves={} - minimum_share_reserves={} (diff={}) should be < tolerance={}", - pool_shares, - min_share_reserves, - reserve_amount_above_minimum, - reserves_drained_tolerance, - ); - } + // Revert to the snapshot and reset the agents' wallets. + num_tests += 1; + chain.revert(id).await?; + alice.reset(Default::default()).await?; + celine.reset(Default::default()).await?; + } + // Assert that we've run at least 50% of the tests. + assert!(num_tests > 50); + Ok(()) + } - // Solidity calculate_max_short worked, but passing that bond amount to open_short failed. - Err(_) => assert!( - false, - "Solidity calculate_max_short produced an insolvent answer!" - ), - } - } + /// Estimate solvency after short, open the short, verify solvency. + #[ignore] + #[tokio::test] + async fn fuzz_sol_solvency_after_short() -> Result<()> { + let solvency_tolerance = fixed!(1e18); + // Set up a random number generator. We use ChaCha8Rng with a randomly + // generated seed, which makes it easy to reproduce test failures given + // the seed. + let mut rng = { + let mut rng = thread_rng(); + let seed = rng.gen(); + ChaCha8Rng::seed_from_u64(seed) + }; + // Initialize the test chain. + let chain = TestChain::new().await?; + let mut alice = chain.alice().await?; + let mut bob = chain.bob().await?; + let mut celine = chain.celine().await?; - // Solidity calculate_max_short failed; verify that rust calculate_max_short fails. - Err(_) => { - // Get the current vault share price & update state. - let vault_share_price = alice.get_state().await?.vault_share_price(); - state.info.vault_share_price = vault_share_price.into(); + // Run the fuzz tests. + for iter in 0..*SLOW_FUZZ_RUNS { + println!(""); + println!("iter {:#?}", iter); + // Snapshot the chain. + let id = chain.snapshot().await?; + // Run the preamble. + initialize_pool_with_random_state(&mut rng, &mut alice, &mut bob, &mut celine).await?; + // Get the current state from solidity. + let mut state = alice.get_state().await?; + // Check that a short is possible. + if state + .solvency_after_short(state.minimum_transaction_amount()) + .is_err() + { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + bob.reset(Default::default()).await?; + celine.reset(Default::default()).await?; + continue; + } - // Get the current checkpoint exposure. - // Solidity reports everything is good, so we run the Rust fns. - let rust_max_bonds = panic::catch_unwind(|| { - state.calculate_absolute_max_short(None, Some(max_iterations)) - }); + // Open the max short. + let max_short = state.calculate_absolute_max_short(None, None)?; + let max_short_deposit_shares = + state.calculate_open_short(max_short, state.vault_share_price())?; + celine + .fund((max_short_deposit_shares + fixed!(100e18)).into()) + .await?; + println!(""); + println!( + "pre_short_exposure_sh ={:#?}", + state.long_exposure().div_up(state.vault_share_price()) + ); + println!( + "bond_amount_shares ={:#?}", + max_short.div_up(state.vault_share_price()) + ); + println!("GUESS"); + let solvency_after_short_guess = state.solvency_after_short(max_short)?; + celine.open_short(max_short, None, None).await?; + alice + .checkpoint(alice.latest_checkpoint().await?, uint256!(0), None) + .await?; - assert!(rust_max_bonds.is_err() || rust_max_bonds.unwrap().is_err()); - } - } + // Check solvency values. + state = alice.get_state().await?; + println!("REAL"); + println!("share_reserves ={:#?}", state.share_reserves()); + println!( + "exposure_shares ={:#?}", + state.long_exposure().div_up(state.vault_share_price()) + ); + println!( + "minimum_share_reserves ={:#?}", + state.minimum_share_reserves() + ); + let solvency_after_short_rs = state.calculate_solvency()?; + // TODO: add solvency function to mock hyperdrive so we can call it. + let solvency_after_short_sol = (state.share_reserves() + - state.long_exposure().div_up(state.vault_share_price())) + - state.minimum_share_reserves(); + + // Ensure solidity & rust solvency values are within tolerance. + let error = if solvency_after_short_guess > solvency_after_short_rs { + solvency_after_short_guess - solvency_after_short_rs + } else { + solvency_after_short_rs - solvency_after_short_guess + }; + assert!( + error <= solvency_tolerance, + "rust error={:#?} > tolerance={:#?}", + error, + solvency_tolerance + ); + let error = if solvency_after_short_guess > solvency_after_short_sol { + solvency_after_short_guess - solvency_after_short_sol + } else { + solvency_after_short_sol - solvency_after_short_guess + }; + assert!( + error <= solvency_tolerance, + "solidity error={:#?} > tolerance={:#?}", + error, + solvency_tolerance + ); // Revert to the snapshot and reset the agent's wallets. chain.revert(id).await?; @@ -1602,8 +1485,18 @@ mod tests { let inc = i_val * fixed!(1e9); // range is 1e9->1e19 increments.push(inc); } + + // Run the fuzz tests. for _ in 0..*FUZZ_RUNS { let state = rng.gen::(); + // Ensure a short is possible. + if state + .solvency_after_short(state.minimum_transaction_amount()) + .is_err() + { + continue; + } + // Vary the baseline scale factor by adjusting the initial bond amount. let bond_amount = rng.gen_range(fixed!(1e10)..=fixed!(100_000_000e18)); // Compute f_x at the baseline bond_amount. diff --git a/crates/hyperdrive-math/src/short/open.rs b/crates/hyperdrive-math/src/short/open.rs index 6dd5dd6c..30b1afa6 100644 --- a/crates/hyperdrive-math/src/short/open.rs +++ b/crates/hyperdrive-math/src/short/open.rs @@ -61,6 +61,13 @@ impl State { )); } + // Verify final solvency. This ensures exposure is accounted for. + // In Hyperdrive Solidity this is done in `applyOpenShort`. + match self.solvency_after_short(bond_amount) { + Ok(_) => (), + Err(e) => return Err(e), + } + // If the open share price hasn't been set, we use the current share // price, since this is what will be set as the checkpoint share price // in this transaction. @@ -96,15 +103,6 @@ impl State { let curve_fee_shares = self .open_short_curve_fee(bond_amount)? .div_up(self.vault_share_price()); - if share_reserves_delta < curve_fee_shares { - return Err(eyre!(format!( - "The transaction curve fee = {}, computed with coefficient = {}, - is too high. It must be less than share reserves delta = {}", - curve_fee_shares, - self.curve_fee(), - share_reserves_delta - ))); - } // If negative interest has accrued during the current checkpoint, we // set the close vault share price to equal the open vault share price. @@ -498,8 +496,7 @@ mod tests { use super::*; use crate::test_utils::{ - agent::HyperdriveMathAgent, - preamble::{get_max_short, initialize_pool_with_random_state}, + agent::HyperdriveMathAgent, preamble::initialize_pool_with_random_state, }; #[tokio::test] @@ -529,16 +526,15 @@ mod tests { alice.advance_time(fixed!(0), fixed!(0)).await?; let original_state = alice.get_state().await?; // Check that a short is possible. - if original_state.effective_share_reserves()? - < original_state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - chain.revert(id).await?; - alice.reset(Default::default()).await?; - bob.reset(Default::default()).await?; - celine.reset(Default::default()).await?; - continue; + match original_state.solvency_after_short(original_state.minimum_transaction_amount()) { + Ok(_) => (), + Err(_) => { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + bob.reset(Default::default()).await?; + celine.reset(Default::default()).await?; + continue; + } } // Get a random short amount. let max_short_amount = original_state.calculate_max_short( @@ -858,18 +854,16 @@ mod tests { alice.initialize(fixed_rate, contribution, None).await?; let mut state = alice.get_state().await?; - // Check that a short is possible. - if state.effective_share_reserves()? - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - chain.revert(id).await?; - alice.reset(Default::default()).await?; - bob.reset(Default::default()).await?; - continue; + // Check that a short is possible & generate a random short amount. + match state.solvency_after_short(state.minimum_transaction_amount()) { + Ok(_) => (), + Err(_) => { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + bob.reset(Default::default()).await?; + continue; + } } - let bond_amount = rng.gen_range( state.minimum_transaction_amount()..=bob.calculate_max_short(None).await?, ); @@ -992,15 +986,14 @@ mod tests { // Check that a short is possible. let state = alice.get_state().await?; - if state.effective_share_reserves()? - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - chain.revert(id).await?; - alice.reset(Default::default()).await?; - bob.reset(Default::default()).await?; - continue; + match state.solvency_after_short(state.minimum_transaction_amount()) { + Ok(_) => (), + Err(_) => { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + bob.reset(Default::default()).await?; + continue; + } } // Bob opens a short with a random bond amount. Before opening the @@ -1157,16 +1150,15 @@ mod tests { let min_txn_amount = state.minimum_transaction_amount(); // Check that a short is possible. - if state.effective_share_reserves()? - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - chain.revert(id).await?; - alice.reset(Default::default()).await?; - bob.reset(Default::default()).await?; - celine.reset(Default::default()).await?; - continue; + match state.solvency_after_short(state.minimum_transaction_amount()) { + Ok(_) => (), + Err(_) => { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + bob.reset(Default::default()).await?; + celine.reset(Default::default()).await?; + continue; + } } let max_short = celine.calculate_max_short(None).await?; @@ -1275,7 +1267,7 @@ mod tests { for _ in 0..*FAST_FUZZ_RUNS { let state = rng.gen::(); let open_vault_share_price = rng.gen_range(fixed!(1e5)..=state.vault_share_price()); - match get_max_short(state.clone(), None) { + match state.calculate_absolute_max_short(None, None) { Ok(max_short_bonds) => { let bond_amount = rng.gen_range(state.minimum_transaction_amount()..=max_short_bonds); diff --git a/crates/hyperdrive-math/src/test_utils/agent.rs b/crates/hyperdrive-math/src/test_utils/agent.rs index c7eadcba..287c4a80 100644 --- a/crates/hyperdrive-math/src/test_utils/agent.rs +++ b/crates/hyperdrive-math/src/test_utils/agent.rs @@ -1,4 +1,4 @@ -use std::{cmp::min, collections::btree_map::Entry}; +use std::collections::btree_map::Entry; use ethers::{prelude::EthLogDecode, signers::LocalWallet, types::U256}; use eyre::Result; @@ -173,39 +173,26 @@ impl HyperdriveMathAgent for Agent, ChaCha8Rng> { ) -> Result> { let budget = self.wallet.base * (fixed!(1e18) - maybe_slippage_tolerance.unwrap_or(fixed!(0.01e18))); - let latest_checkpoint = self.latest_checkpoint().await?; let Checkpoint { vault_share_price: open_vault_share_price, .. } = self.get_checkpoint(latest_checkpoint).await?; let state = self.get_state().await?; - - // We linearly interpolate between the current spot price and the minimum - // price that the pool can support. This is a conservative estimate of - // the short's realized price. - let conservative_price = { - // We estimate the minimum price that short will pay by a - // weighted average of the spot price and the minimum possible - // spot price the pool can quote. We choose the weights so that this - // is an underestimate of the worst case realized price. - let spot_price = state.calculate_spot_price_down()?; - let min_price = state.calculate_min_spot_price()?; - - // Calculate the linear interpolation. - let base_reserves = FixedPoint::from(state.info.vault_share_price) - * (FixedPoint::from(state.info.share_reserves)); - let weight = (min(self.wallet.base, base_reserves) / base_reserves) - .pow(fixed!(1e18) - FixedPoint::from(self.get_config().time_stretch))?; - spot_price * (fixed!(1e18) - weight) + min_price * weight - }; - - state.calculate_max_short( - budget, - open_vault_share_price, - Some(conservative_price), - None, - ) + let absolute_max_bond_amount = state.calculate_absolute_max_short(None, None)?; + let absolute_max_base_amount = + state.calculate_open_short(absolute_max_bond_amount, open_vault_share_price.into())?; + if budget >= absolute_max_base_amount { + Ok(absolute_max_base_amount) + } else { + state.calculate_short_bonds_given_deposit( + budget, + open_vault_share_price.into(), + absolute_max_bond_amount, + None, + None, + ) + } } #[instrument(skip(self))] diff --git a/crates/hyperdrive-math/src/test_utils/preamble.rs b/crates/hyperdrive-math/src/test_utils/preamble.rs index 1ef69e55..f97a7ef5 100644 --- a/crates/hyperdrive-math/src/test_utils/preamble.rs +++ b/crates/hyperdrive-math/src/test_utils/preamble.rs @@ -23,7 +23,7 @@ pub async fn initialize_pool_with_random_state( celine: &mut Agent, ChaCha8Rng>, ) -> Result<()> { // Get random pool liquidity & fixed rate. - let pool_initial_contribution = rng.gen_range(fixed!(1_000e18)..=fixed!(1_000_000_000e18)); + let pool_initial_contribution = rng.gen_range(fixed!(10_000e18)..=fixed!(1_000_000_000e18)); let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18)); // Alice initializes the pool. @@ -32,54 +32,72 @@ pub async fn initialize_pool_with_random_state( .initialize(fixed_rate, pool_initial_contribution, None) .await?; - // Advance the time for over a term and make trades in some of the checkpoints. - let mut time_remaining = alice.get_config().position_duration; - while time_remaining > uint256!(0) { - let mut state = alice.get_state().await?; - let min_txn = state.minimum_transaction_amount(); - + // Determine a bounded random amount of trade sequences. + let mut state = alice.get_state().await?; + let total_time = fixed!(1.1e18) * state.position_duration(); + let num_steps = rng.gen_range(fixed!(1e18)..=fixed!(3e18)); + let time_delta = total_time / num_steps; + + // Advance the time for over a position term and make trades in some of the + // checkpoints. + let min_txn = state.minimum_transaction_amount(); + let mut time_remaining = total_time.change_type::()?; + while time_remaining > fixed!(0) { + state = alice.get_state().await?; // Bob opens a long. - let max_long = get_max_long(state, None)?; + let max_long = get_max_long(&state, None)?; let long_amount = rng.gen_range(min_txn..=max_long); - bob.fund(long_amount + fixed!(10e18)).await?; // Fund a little extra for slippage. + // Fund extra for slippage. + bob.fund(long_amount + fixed!(10e18)).await?; bob.open_long(long_amount, None, None).await?; // Celine opens a short. state = alice.get_state().await?; - - // Open a short. - let max_short = get_max_short(state, None)?; - let short_amount = rng.gen_range(min_txn..=max_short); - celine.fund(short_amount + fixed!(10e18)).await?; // Fund a little extra for slippage. - celine.open_short(short_amount, None, None).await?; - + let max_short = state.absolute_max_short_guess()?; // Less accurate but faster to compute. + let short_bond_amount = if max_short == min_txn { + min_txn + } else { + rng.gen_range(min_txn..=max_short) + }; + let user_deposit_shares = + state.calculate_open_short(short_bond_amount, state.vault_share_price())?; + // Fund extra for slippage. + celine + .fund(user_deposit_shares * state.vault_share_price() + fixed!(10e18)) + .await?; + celine.open_short(short_bond_amount, None, None).await?; // Advance the time and mint all of the intermediate checkpoints. - let multiplier = rng.gen_range(fixed!(10e18)..=fixed!(100e18)); - let delta = FixedPoint::from(time_remaining) - .min(FixedPoint::from(alice.get_config().checkpoint_duration) * multiplier); - time_remaining -= U256::from(delta); let variable_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18)); - alice.advance_time(variable_rate, delta).await?; + alice.advance_time(variable_rate, time_delta).await?; + time_remaining -= time_delta.change_type::()?; } - // Mint a checkpoint to close any matured positions. + // Add some liquidity again to make sure future bots can make trades. + let state = alice.get_state().await?; + let min_share_reserves = state + .calculate_min_share_reserves_given_exposure()? + .change_type::()?; + let positive_share_adjustment = + FixedPoint::::try_from(state.share_adjustment().min(0.into()))?; + let min_base = (min_share_reserves + positive_share_adjustment) * state.vault_share_price() + + state.long_exposure(); + let liquidity_amount = rng.gen_range(min_base..=min_base + fixed!(100_000e18)); + alice.fund(liquidity_amount).await?; + alice.add_liquidity(liquidity_amount, None).await?; + // Advance time logner than a position duration & checkpoint one last time + // to ensure all positions are closed. + let variable_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18)); alice - .checkpoint(alice.latest_checkpoint().await?, uint256!(0), None) + .advance_time(variable_rate, state.position_duration() * fixed!(2e18)) .await?; - - // Add some liquidity again to make sure future bots can make trades. - let liquidity_amount = rng.gen_range(fixed!(1_000e18)..=fixed!(100_000_000e18)); - let exposure = alice.get_state().await?.long_exposure(); - alice.fund(liquidity_amount + exposure).await?; alice - .add_liquidity(liquidity_amount + exposure, None) + .checkpoint(alice.latest_checkpoint().await?, uint256!(0), None) .await?; - Ok(()) } /// Conservative and safe estimate of the maximum long. -fn get_max_long(state: State, maybe_max_num_tries: Option) -> Result> { +fn get_max_long(state: &State, maybe_max_num_tries: Option) -> Result> { let max_num_tries = maybe_max_num_tries.unwrap_or(10); let checkpoint_exposure = I256::from(0); @@ -127,78 +145,10 @@ fn get_max_long(state: State, maybe_max_num_tries: Option) -> Result) -> Result> { - let max_num_tries = maybe_max_num_tries.unwrap_or(10); - - // We linearly interpolate between the current spot price and the minimum - // price that the pool can support. This is a conservative estimate of - // the short's realized price. - let conservative_price = { - // We estimate the minimum price that short will pay by a - // weighted average of the spot price and the minimum possible - // spot price the pool can quote. We choose the weights so that this - // is an underestimate of the worst case realized price. - let spot_price = state.calculate_spot_price_down()?; - let min_price = state.calculate_min_spot_price()?; - - // Calculate the linear interpolation. - let weight = fixed!(1e18).pow(fixed!(1e18) - state.time_stretch())?; - spot_price * (fixed!(1e18) - weight) + min_price * weight - }; - - // Compute the max short. - let mut max_short = match panic::catch_unwind(|| { - state.calculate_max_short( - U256::from(U128::MAX), - state.vault_share_price(), - Some(conservative_price), - Some(5), - ) - }) { - Ok(max_short_no_panic) => match max_short_no_panic { - Ok(max_short) => max_short, - Err(_) => state.share_reserves() / state.vault_share_price() * fixed!(10e18), - }, - Err(_) => state.share_reserves() / state.vault_share_price() * fixed!(10e18), - }; - let mut num_tries = 0; - let mut success = false; - while !success { - max_short = match panic::catch_unwind(|| { - state.calculate_open_short(max_short, state.vault_share_price()) - }) { - Ok(short_result_no_panic) => match short_result_no_panic { - Ok(_) => { - success = true; - max_short - } - Err(_) => max_short / fixed!(10e18), - }, - Err(_) => max_short / fixed!(10e18), - }; - if max_short < state.minimum_transaction_amount() { - return Err(eyre!( - "max_short={} was less than minimum_transaction_amount={}.", - max_short, - state.minimum_transaction_amount() - )); - } - num_tries += 1; - if num_tries > max_num_tries { - return Err(eyre!( - "Failed to find a max short. Last attempted value was {}", - max_short, - )); - } - } - Ok(max_short) -} - #[cfg(test)] mod tests { use fixedpointmath::fixed; - use hyperdrive_test_utils::{chain::TestChain, constants::FUZZ_RUNS}; + use hyperdrive_test_utils::{chain::TestChain, constants::SLOW_FUZZ_RUNS}; use rand::{thread_rng, Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; @@ -222,7 +172,7 @@ mod tests { let mut bob = chain.bob().await?; let mut celine = chain.celine().await?; - for _ in 0..*FUZZ_RUNS { + for _ in 0..*SLOW_FUZZ_RUNS { // Snapshot the chain. let id = chain.snapshot().await?; @@ -260,7 +210,7 @@ mod tests { let mut alice = chain.alice().await?; let mut bob = chain.bob().await?; let mut celine = chain.celine().await?; - for _ in 0..*FUZZ_RUNS { + for _ in 0..*SLOW_FUZZ_RUNS { // Snapshot the chain. let id = chain.snapshot().await?; // Run the preamble. @@ -268,22 +218,14 @@ mod tests { // Get state. let state = alice.get_state().await?; // Check that a short is possible. - if state.effective_share_reserves()? - < state - .calculate_min_share_reserves_given_exposure()? - .change_type::()? - { - chain.revert(id).await?; - alice.reset(Default::default()).await?; - bob.reset(Default::default()).await?; - celine.reset(Default::default()).await?; - continue; - } + assert!(state + .solvency_after_short(state.minimum_transaction_amount()) + .is_ok()); // Open the max short. - let max_short = bob.calculate_max_short(None).await?; + let max_short = celine.calculate_max_short(None).await?; assert!(max_short >= state.minimum_transaction_amount()); - bob.fund(max_short + fixed!(10e18)).await?; - bob.open_short(max_short, None, None).await?; + celine.fund(max_short + fixed!(10e18)).await?; + celine.open_short(max_short, None, None).await?; // Reset the chain & agents. chain.revert(id).await?; alice.reset(Default::default()).await?;