diff --git a/.github/workflows/rust_test.yml b/.github/workflows/rust_test.yml index f26c8b50..18f24fef 100644 --- a/.github/workflows/rust_test.yml +++ b/.github/workflows/rust_test.yml @@ -39,7 +39,7 @@ jobs: - uses: actions-rs/toolchain@v1 with: - toolchain: nightly-2024-05-02 + toolchain: nightly override: true components: rustfmt, clippy diff --git a/bindings/hyperdrivepy/src/hyperdrive_state_methods.rs b/bindings/hyperdrivepy/src/hyperdrive_state_methods.rs index bc75c312..3b3c9208 100644 --- a/bindings/hyperdrivepy/src/hyperdrive_state_methods.rs +++ b/bindings/hyperdrivepy/src/hyperdrive_state_methods.rs @@ -38,7 +38,7 @@ impl HyperdriveState { } pub fn calculate_spot_price(&self) -> PyResult { - let result_fp = self.state.calculate_spot_price().map_err(|err| { + let result_fp = self.state.calculate_spot_price_down().map_err(|err| { PyErr::new::(format!("calculate_spot_price: {}", err)) })?; let result = U256::from(result_fp).to_string(); diff --git a/crates/hyperdrive-math/src/lib.rs b/crates/hyperdrive-math/src/lib.rs index 9084a468..18b61c0c 100644 --- a/crates/hyperdrive-math/src/lib.rs +++ b/crates/hyperdrive-math/src/lib.rs @@ -126,15 +126,20 @@ impl State { Self { config, info } } - /// Calculates the pool's spot price. - pub fn calculate_spot_price(&self) -> Result> { - YieldSpace::calculate_spot_price(self) + /// Calculates the pool's spot price, rounding down. + pub fn calculate_spot_price_down(&self) -> Result> { + YieldSpace::calculate_spot_price_down(self) + } + + /// Calculates the pool's spot price, rounding up. + pub fn calculate_spot_price_up(&self) -> Result> { + YieldSpace::calculate_spot_price_up(self) } /// Calculate the pool's current spot (aka "fixed") rate. pub fn calculate_spot_rate(&self) -> Result> { Ok(calculate_rate_given_fixed_price( - self.calculate_spot_price()?, + self.calculate_spot_price_down()?, self.position_duration(), )) } @@ -364,8 +369,8 @@ mod tests { state.info.shorts_outstanding = uint256!(0); state.info.short_average_maturity_time = uint256!(0); // Make sure we're still solvent - if state.calculate_spot_price()? < state.calculate_min_spot_price()? - || state.calculate_spot_price()? > fixed!(1e18) + if state.calculate_spot_price_down()? < state.calculate_min_spot_price()? + || state.calculate_spot_price_down()? > fixed!(1e18) || state.calculate_solvency().is_err() { continue; @@ -403,7 +408,7 @@ mod tests { new_state.info.share_reserves = target_share_reserves.into(); new_state.info.bond_reserves = target_bond_reserves.into(); if new_state.calculate_solvency().is_err() - || new_state.calculate_spot_price()? > fixed!(1e18) + || new_state.calculate_spot_price_down()? > fixed!(1e18) { continue; } diff --git a/crates/hyperdrive-math/src/long/close.rs b/crates/hyperdrive-math/src/long/close.rs index 575a01cc..bbe2ec0b 100644 --- a/crates/hyperdrive-math/src/long/close.rs +++ b/crates/hyperdrive-math/src/long/close.rs @@ -82,7 +82,7 @@ impl State { ) -> Result> { let bond_amount = bond_amount.into(); - let spot_price = self.calculate_spot_price()?; + let spot_price = self.calculate_spot_price_down()?; if spot_price > fixed!(1e18) { return Err(eyre!("Negative fixed interest!")); } @@ -231,7 +231,7 @@ mod tests { // Ensure curve_fee is smaller than spot_price to avoid overflows // on the hyperdrive valuation, as that'd mean having to pay a larger // amount of fees than the current value of the long. - let spot_price = state.calculate_spot_price()?; + let spot_price = state.calculate_spot_price_down()?; if state.curve_fee() * (fixed!(1e18) - spot_price) > spot_price { continue; } diff --git a/crates/hyperdrive-math/src/long/fees.rs b/crates/hyperdrive-math/src/long/fees.rs index fe323689..77a1fa7d 100644 --- a/crates/hyperdrive-math/src/long/fees.rs +++ b/crates/hyperdrive-math/src/long/fees.rs @@ -18,7 +18,7 @@ impl State { // NOTE: Round up to overestimate the curve fee. Ok(self .curve_fee() - .mul_up(fixed!(1e18).div_up(self.calculate_spot_price()?) - fixed!(1e18)) + .mul_up(fixed!(1e18).div_up(self.calculate_spot_price_down()?) - fixed!(1e18)) .mul_up(base_amount)) } @@ -43,7 +43,7 @@ impl State { // NOTE: Round down to underestimate the governance curve fee. Ok(curve_fee .mul_down(self.governance_lp_fee()) - .mul_down(self.calculate_spot_price()?)) + .mul_down(self.calculate_spot_price_down()?)) } /// Calculates the curve fee paid when closing longs for a given bond @@ -69,7 +69,7 @@ impl State { // NOTE: Round up to overestimate the curve fee. Ok(self .curve_fee() - .mul_up(fixed!(1e18) - self.calculate_spot_price()?) + .mul_up(fixed!(1e18) - self.calculate_spot_price_down()?) .mul_up(bond_amount) .mul_div_up(normalized_time_remaining, self.vault_share_price())) } diff --git a/crates/hyperdrive-math/src/long/max.rs b/crates/hyperdrive-math/src/long/max.rs index c4a9accb..ec4ca1c9 100644 --- a/crates/hyperdrive-math/src/long/max.rs +++ b/crates/hyperdrive-math/src/long/max.rs @@ -20,7 +20,7 @@ impl State { / (fixed!(1e18) + self .curve_fee() - .mul_up(fixed!(1e18).div_up(self.calculate_spot_price()?) - fixed!(1e18))) + .mul_up(fixed!(1e18).div_up(self.calculate_spot_price_down()?) - fixed!(1e18))) .mul_up(fixed!(1e18) - self.flat_fee())) } @@ -208,7 +208,8 @@ impl State { + self .curve_fee() .mul_up( - fixed!(1e18).div_up(self.calculate_spot_price()?) - fixed!(1e18), + fixed!(1e18).div_up(self.calculate_spot_price_down()?) + - fixed!(1e18), ) .mul_up(fixed!(1e18) - self.flat_fee())) .div_up(fixed!(1e18) - self.flat_fee())) @@ -226,7 +227,7 @@ impl State { // // y_t = inner * ((1 + curveFee * (1 / p_0 - 1) * (1 - flatFee)) / (1 - flatFee)) ** (1 / t_s) let fee_adjustment = self.curve_fee() - * (fixed!(1e18) / self.calculate_spot_price()? - fixed!(1e18)) + * (fixed!(1e18) / self.calculate_spot_price_down()? - fixed!(1e18)) * (fixed!(1e18) - self.flat_fee()); let target_bond_reserves = ((fixed!(1e18) + fee_adjustment) / (fixed!(1e18) - self.flat_fee())) @@ -265,7 +266,7 @@ impl State { ) -> Result> { // Calculate an initial estimate of the max long by using the spot price as // our conservative price. - let spot_price = self.calculate_spot_price()?; + let spot_price = self.calculate_spot_price_down()?; let guess = self.max_long_estimate(spot_price, spot_price, checkpoint_exposure)?; // We know that the spot price is 1 when the absolute max base amount is @@ -426,7 +427,7 @@ impl State { base_amount: FixedPoint, ) -> Result> { let derivative = self.calculate_open_long_derivative(base_amount)?; - let spot_price = self.calculate_spot_price()?; + let spot_price = self.calculate_spot_price_down()?; Ok( (derivative + self.governance_lp_fee() * self.curve_fee() * (fixed!(1e18) - spot_price) @@ -485,7 +486,7 @@ mod tests { state.info.share_adjustment, )? .into(), - state.calculate_spot_price()?.into(), + state.calculate_spot_price_down()?.into(), ) .call() .await diff --git a/crates/hyperdrive-math/src/long/open.rs b/crates/hyperdrive-math/src/long/open.rs index 43e9ced9..a2742957 100644 --- a/crates/hyperdrive-math/src/long/open.rs +++ b/crates/hyperdrive-math/src/long/open.rs @@ -104,7 +104,7 @@ impl State { // Finish computing the derivative. derivative -= - self.curve_fee() * ((fixed!(1e18) / self.calculate_spot_price()?) - fixed!(1e18)); + self.curve_fee() * ((fixed!(1e18) / self.calculate_spot_price_down()?) - fixed!(1e18)); Ok(derivative) } @@ -155,7 +155,7 @@ impl State { ) -> Result> { let state = self.calculate_pool_state_after_open_long(base_amount, maybe_bond_pool_delta)?; - state.calculate_spot_price() + state.calculate_spot_price_down() } /// Calculate the spot rate after a long has been opened. @@ -336,7 +336,7 @@ mod tests { // Verify that the predicted spot price is equal to the ending spot // price. These won't be exactly equal because the vault share price // increases between the prediction and opening the long. - let actual_spot_price = bob.get_state().await?.calculate_spot_price()?; + let actual_spot_price = bob.get_state().await?.calculate_spot_price_down()?; let delta = if actual_spot_price > expected_spot_price { actual_spot_price - expected_spot_price } else { diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index 01bb7139..e98f9e85 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -299,7 +299,7 @@ impl State { // g'(x) = \phi_g \phi_c (1 - p_0) let gov_fee_derivative = self.governance_lp_fee() * self.curve_fee() - * (fixed!(1e18) - self.calculate_spot_price()?); + * (fixed!(1e18) - self.calculate_spot_price_down()?); // a(x) = mu * (z_{e,0} + 1/c (x - g(x)) let inner_numerator = self.mu() @@ -401,7 +401,7 @@ impl State { } let share_delta = ending_share_reserves - self.share_reserves(); let fees = fixed!(1e18) - - (fixed!(1e18) - self.calculate_spot_price()?) + - (fixed!(1e18) - self.calculate_spot_price_down()?) * self.curve_fee() * self.governance_lp_fee(); let base_delta = self.vault_share_price().mul_div_down(share_delta, fees); @@ -620,7 +620,7 @@ mod tests { // Check that our resulting price is under the max let current_state = alice.get_state().await?; - let spot_price_after_long = current_state.calculate_spot_price()?; + let spot_price_after_long = current_state.calculate_spot_price_down()?; assert!( max_spot_price_before_long > spot_price_after_long, "Resulting price is greater than the max." diff --git a/crates/hyperdrive-math/src/short/close.rs b/crates/hyperdrive-math/src/short/close.rs index e85ea5a8..818e471f 100644 --- a/crates/hyperdrive-math/src/short/close.rs +++ b/crates/hyperdrive-math/src/short/close.rs @@ -194,7 +194,7 @@ impl State { Ok(fixed!(1e18) - self .curve_fee() - .mul_up(fixed!(1e18) - self.calculate_spot_price()?)) + .mul_up(fixed!(1e18) - self.calculate_spot_price_down()?)) } /// Calculates the amount of shares the trader will receive after fees for closing a short @@ -224,7 +224,7 @@ impl State { let mut state: State = self.clone(); state.info.bond_reserves -= bond_reserves_delta.into(); state.info.share_reserves += share_curve_delta.into(); - state.calculate_spot_price()? + state.calculate_spot_price_down()? }; let max_spot_price = self.calculate_close_short_max_spot_price()?; if short_curve_spot_price > max_spot_price { @@ -244,7 +244,7 @@ impl State { let mut state: State = self.clone(); state.info.bond_reserves -= bond_reserves_delta.into(); state.info.share_reserves += share_curve_delta_with_fees.into(); - state.calculate_spot_price()? + state.calculate_spot_price_down()? }; if share_curve_delta_with_fees_spot_price > fixed!(1e18) { return Err(eyre!("InsufficientLiquidity: Negative Interest")); @@ -293,7 +293,7 @@ impl State { let open_vault_share_price = open_vault_share_price.into(); let close_vault_share_price = close_vault_share_price.into(); - let spot_price = self.calculate_spot_price()?; + let spot_price = self.calculate_spot_price_down()?; if spot_price > fixed!(1e18) { return Err(eyre!("Negative fixed interest!")); } @@ -513,7 +513,7 @@ mod tests { let state = rng.gen::(); let result = state.calculate_close_short( (state.config.minimum_transaction_amount - 10).into(), - state.calculate_spot_price()?, + state.calculate_spot_price_down()?, state.vault_share_price(), 0.into(), 0.into(), diff --git a/crates/hyperdrive-math/src/short/fees.rs b/crates/hyperdrive-math/src/short/fees.rs index 9b0fcb75..a23c4097 100644 --- a/crates/hyperdrive-math/src/short/fees.rs +++ b/crates/hyperdrive-math/src/short/fees.rs @@ -17,7 +17,7 @@ impl State { // NOTE: Round up to overestimate the curve fee. Ok(self .curve_fee() - .mul_up(fixed!(1e18) - self.calculate_spot_price()?) + .mul_up(fixed!(1e18) - self.calculate_spot_price_down()?) .mul_up(bond_amount)) } @@ -43,6 +43,21 @@ impl State { Ok(curve_fee.mul_down(self.governance_lp_fee())) } + /// Calculate the total fees to be removed from the short principal when + /// opening a short for a given bond amount. + pub fn calculate_open_short_total_fee_shares( + &self, + bond_amount: FixedPoint, + ) -> Result> { + let curve_fee_base = self.open_short_curve_fee(bond_amount)?; + let curve_fee_shares = curve_fee_base.div_up(self.vault_share_price()); + let gov_curve_fee_shares = self + .open_short_governance_fee(bond_amount, Some(curve_fee_base))? + .div_up(self.vault_share_price()); + let total_fee_shares = curve_fee_shares - gov_curve_fee_shares; + Ok(total_fee_shares) + } + /// Calculates the curve fee paid when opening shorts with a given bond /// amount. /// @@ -65,7 +80,7 @@ impl State { // NOTE: Round up to overestimate the curve fee. Ok(self .curve_fee() - .mul_up(fixed!(1e18) - self.calculate_spot_price()?) + .mul_up(fixed!(1e18) - self.calculate_spot_price_down()?) .mul_up(bond_amount) .mul_div_up(normalized_time_remaining, self.vault_share_price())) } diff --git a/crates/hyperdrive-math/src/short/max.rs b/crates/hyperdrive-math/src/short/max.rs index 21c39775..dbc60399 100644 --- a/crates/hyperdrive-math/src/short/max.rs +++ b/crates/hyperdrive-math/src/short/max.rs @@ -11,8 +11,9 @@ impl State { /// minimum price that the pool can support. This is the price at which the /// share reserves are equal to the minimum share reserves. /// - /// We can solve for the bond reserves `$y_{\text{max}}$` implied by the share reserves - /// being equal to `$z_{\text{min}}$` using the current k value: + /// We can solve for the bond reserves `$y_{\text{max}}$` implied by the + /// share reserves being equal to `$z_{\text{min}}$` using the current $k$ + /// value: /// /// ```math /// k = \tfrac{c}{\mu} \cdot \left( \mu \cdot z_{min} \right)^{1 - t_s} @@ -38,6 +39,34 @@ impl State { .pow(self.time_stretch()) } + /// Calculate the minimum share reserves allowed by the pool given the + /// current exposure and share adjustment. + pub fn calculate_min_share_reserves( + &self, + checkpoint_exposure: I256, + ) -> Result> { + // We have the twin constraints that `$z \geq z_{min} + exposure$` and + // `$z - \zeta \geq z_{min}$`. Combining these together, we calculate + // the share reserves after a max short as + // `$z_{optimal} = z_{min} + max(0, \zeta) + exposure$`. + let exposure_shares = { + let checkpoint_exposure = FixedPoint::try_from(checkpoint_exposure.max(I256::zero()))?; + if self.long_exposure() < checkpoint_exposure { + return Err(eyre!( + "expected long_exposure={:#?} >= checkpoint_exposure={:#?}.", + self.long_exposure(), + checkpoint_exposure + )); + } else { + (self.long_exposure() - checkpoint_exposure).div_up(self.vault_share_price()) + } + }; + let min_share_reserves = self.minimum_share_reserves() + + FixedPoint::try_from(self.share_adjustment().max(I256::zero()))? + + exposure_shares; + Ok(min_share_reserves) + } + /// Use Newton's method with rate reduction to find the amount of bonds /// shorted for a given base deposit amount. pub fn calculate_short_bonds_given_deposit( @@ -103,7 +132,7 @@ impl State { let base_amount_derivative = self.calculate_open_short_derivative( last_good_bond_amount, open_vault_share_price, - Some(self.calculate_spot_price()?), + Some(self.calculate_spot_price_down()?), )?; let dy = loss.div_up(base_amount_derivative); // div up to discourage dy == 0 @@ -203,10 +232,12 @@ impl State { } // To avoid the case where Newton's method overshoots and stays on - // the invalid side of the optimization equation (i.e., when deposit > budget), - // we artificially set the target budget to be less than the actual budget. + // the invalid side of the optimization equation (i.e., when deposit > + // budget), we artificially set the target budget to be less than the + // actual budget. // - // If the budget is less than the minimum transaction amount, then we return early. + // If the budget is less than the minimum transaction amount, then we + // return early. let target_budget = if budget < self.minimum_transaction_amount() { return Err(eyre!( "expected budget={} >= min_transaction_amount={}", @@ -220,7 +251,8 @@ impl State { else if budget == self.minimum_transaction_amount() { return Ok(self.minimum_transaction_amount()); } - // If the budget is greater than the minimum transaction amount, then we set the target budget. + // If the budget is greater than the minimum transaction amount, then we + // set the target budget. else { budget - self.minimum_transaction_amount() }; @@ -236,14 +268,16 @@ impl State { // Assuming the budget is infinite, find the largest possible short that // can be opened. If the short satisfies the budget, this is the max // short amount. - let spot_price = self.calculate_spot_price()?; - // The initial guess should be guaranteed correct, and we should only get better from there. + let spot_price = self.calculate_spot_price_down()?; + // The initial guess should be guaranteed correct, and we should only + // get better from there. let absolute_max_bond_amount = self.calculate_absolute_max_short( spot_price, checkpoint_exposure, maybe_max_iterations, )?; - // The max bond amount might be below the pool's minimum. If so, no short can be opened. + // The max bond amount might be below the pool's minimum. If so, no + // short can be opened. if absolute_max_bond_amount < self.minimum_transaction_amount() { return Err(eyre!("No solvent short is possible.")); } @@ -278,23 +312,25 @@ impl State { // function as: // // ```math - // F(x) = B - D(x) + // F(\Delta y) = B - D(\Delta y) // ``` // - // Since `$B$` is just a constant, `$F'(x) = -D'(x)$`. Given the current guess - // of `$x_n$`, Newton's method gives us an updated guess of `$x_{n+1}$`: + // Since `$B$` is just a constant, `$F'(\Delta y) = -D'(\Delta y)$`. + // Given the current guess of `$x_n$`, Newton's method gives us an + // updated guess of `$x_{n+1}$`: // // ```math // \begin{aligned} - // x_{n+1} &= x_n - \tfrac{F(x_n)}{F'(x_n)} \\ - // &= x_n + \tfrac{B - D(x_n)}{D'(x_n)} + // \Delta y_{n+1} &= x_n - \tfrac{F(\Delta y_n)}{F'(\Delta y_n)} \\ + // &= \Delta y_n + \tfrac{B - D(\Delta y_n)}{D'(\Delta y_n)} // \end{aligned} // ``` // // The guess that we make is very important in determining how quickly // we converge to the solution. // - // TODO: This can get stuck in a loop if the Newton update pushes the bond amount to be too large. + // TODO: This can get stuck in a loop if the Newton update pushes the + // bond amount to be too large. for _ in 0..maybe_max_iterations.unwrap_or(7) { let deposit = match self.calculate_open_short(max_bond_amount, open_vault_share_price) { Ok(valid_deposit) => valid_deposit, @@ -371,25 +407,26 @@ impl State { // is an overestimate or if a conservative price isn't given, we revert // to using the theoretical worst case scenario as our guess. if let Some(conservative_price) = maybe_conservative_price { - // Given our conservative price `$p_c$`, we can write the short deposit - // function as: + // Given our conservative price `$p_c$`, we can write the short + // deposit function as: // // ```math - // D(x) = \left( \tfrac{c}{c_0} - $p_c$ \right) \cdot x - // + \phi_{flat} \cdot x + \phi_{curve} \cdot (1 - p) \cdot x + // D(\Delta y) = \left( \tfrac{c}{c_0} - $p_c$ \right) + // \cdot \Delta y + \phi_{flat} \cdot \Delta y + // + \phi_{curve} \cdot (1 - p) \cdot \Delta y // ``` // - // We then solve for $x^*$ such that $D(x^*) = B$, which gives us a - // guess of: + // We then solve for $\Delta y^*$ such that $D(\Delta y^*) = B$, + // which gives us a guess of: // // ```math - // x^* = \tfrac{B}{\tfrac{c}{c_0} - $p_c$ + \phi_{flat} + // \Delta y^* = \tfrac{B}{\tfrac{c}{c_0} - $p_c$ + \phi_{flat} // + \phi_{curve} \cdot (1 - p)} // ``` // - // If the budget can cover the actual short deposit on `$x^*$`, we - // return it as our guess. Otherwise, we revert to the worst case - // scenario. + // If the budget can cover the actual short deposit on + // `$\Delta y^*$`, we return it as our guess. Otherwise, we revert + // to the worst case scenario. let guess = budget / (self.vault_share_price().div_up(open_vault_share_price) + self.flat_fee() @@ -497,7 +534,7 @@ impl State { // // The guess that we make is very important in determining how quickly // we converge to the solution. - let mut max_bond_guess = self.absolute_max_short_guess(spot_price, checkpoint_exposure)?; + let mut max_bond_guess = self.absolute_max_short_guess(checkpoint_exposure)?; // If the initial guess is insolvent, we need to throw an error. let mut solvency = self.solvency_after_short(max_bond_guess, checkpoint_exposure)?; for _ in 0..maybe_max_iterations.unwrap_or(7) { @@ -534,93 +571,145 @@ impl State { Ok(max_bond_guess) } - /// Calculates an initial guess for the absolute max short. This is a conservative - /// guess that will be less than the true absolute max short, which is what - /// we need to start Newton's method. + /// Calculates an initial guess for the absolute max short. This is a + /// conservative guess that will be less than the true absolute max short, + /// which is what we need to start Newton's method. /// - /// To calculate our guess, we assume an unrealistically good realized - /// price `$p_r$` for opening the short. This allows us to approximate - /// `$P(x) \approx \tfrac{1}{c} \cdot p_r \cdot x$`. Plugging this - /// into our solvency function `$S(x)$`, we get an approximation of our - /// solvency as: + /// To calculate our guess, we start from the equation for computing the + /// final share reserves given an open short for some delta bonds: /// /// ```math - /// S(x) \approx (z_0 - \tfrac{1}{c} \cdot ( - /// p_r - \phi_{c} \cdot (1 - p) + \phi_{g} \cdot \phi_{c} \cdot (1 - p) - /// )) - \tfrac{e_0 - max(e_{c}, 0)}{c} - z_{min} + ///z_1 = \frac{1}{\mu} \cdot \left( \frac{\mu}{c} \cdot \left( k + /// - (y_0 + \Delta y)^{1 - t_s} \right) \right)^{\frac{1}{1 - t_s}} + /// + \phi_c \cdot (1 - p) \cdot (1 - \phi_g) \cdot \frac{\Delta y}{c} /// ``` /// - /// Setting this equal to zero, we can solve for our initial guess: + /// After the open short, this must be greater than or equal to the minimum + /// share reserves, `$z_{\text{min}} + \text{max}_(\zeta, 0) + e$`, where + /// `$e$` is the pool's current total exposure. + /// + /// We can solve for a conservative open short bond amount by using a + /// conservative linear approximation of the nonlinear YieldSpace term. The + /// Taylor Expansion provides such an approximation: /// /// ```math - /// x = \frac{c \cdot (s_0 + \tfrac{max(e_{c}, 0)}{c})}{ - /// p_r - \phi_{c} \cdot (1 - p) + \phi_{g} \cdot \phi_{c} \cdot (1 - p) - /// } + /// z_{1,ys} \ge z_0 - \zeta - \frac{p}{c} \cdot \Delta y_{\text{max}} /// ``` - fn absolute_max_short_guess( - &self, - spot_price: FixedPoint, - checkpoint_exposure: I256, - ) -> Result> { - let checkpoint_exposure_shares = - FixedPoint::try_from(checkpoint_exposure.max(I256::zero()))? - .div_down(self.vault_share_price()); - // solvency = share_reserves - long_exposure / vault_share_price - min_share_reserves - let solvency = self.calculate_solvency()? + checkpoint_exposure_shares; - let guess = self.vault_share_price().mul_down(solvency); - let curve_fee = self.curve_fee().mul_down(fixed!(1e18) - spot_price); - let gov_curve_fee = self.governance_lp_fee().mul_down(curve_fee); - Ok(guess.div_down(spot_price - curve_fee + gov_curve_fee)) + /// + /// Using this, we can produce a conservative delta bond estimate: + /// + /// ```math + /// \Delta y_{\text{max}} \ge \Tilde{\Delta y} = \frac{c \cdot \left( z_0 + /// \zeta - \left( z_{\text{min}} + \text{max}(\zeta, 0) + /// + e \right) \right)}{p + \phi_c \cdot (1 - p) \cdot (1 - \phi_g)} + /// ``` + /// + /// While this should always provide a conservative estimate, we include + /// an iterative loop that checks solvency and refines the result as a + /// precautionary measure. + fn absolute_max_short_guess(&self, checkpoint_exposure: I256) -> Result> { + // We cannot directly solve for a valid delta y that produces the + // minimum effective share reserves, so instead we use a linear + // approximation of the YieldSpace component. + let min_share_reserves = self.calculate_min_share_reserves(checkpoint_exposure)?; + if self.effective_share_reserves()? < min_share_reserves { + return Err(eyre!( + "Current effective pool share reserves = {:#?} are below the minimum = {:#?}.", + self.effective_share_reserves()?, + min_share_reserves + )); + } + // Use a linear estimate that lies below the YieldSpace curve. + // z0 - zeta - z1 + let effective_shares_minus_min_shares = + self.effective_share_reserves()? - min_share_reserves; + // ø_c * (1 - ø_g) * (1 - p) + let fee_component = self + .curve_fee() + .mul_up(fixed!(1e18) - self.governance_lp_fee()) + .mul_up(fixed!(1e18) - self.calculate_spot_price_down()?); + // (c * (z0 - zeta - z1)) / (p + ø_c * (1 - ø_g) * (1 - p)) + let mut conservative_bond_delta = self.vault_share_price().mul_div_up( + effective_shares_minus_min_shares, + self.calculate_spot_price_up()? + fee_component, + ); + // Iteratively adjust to ensure solvency. + loop { + match self.solvency_after_short(conservative_bond_delta, checkpoint_exposure) { + Ok(_) => break, + Err(_) => { + conservative_bond_delta /= fixed!(2e18); + if conservative_bond_delta < self.minimum_transaction_amount() { + return Ok(self.minimum_transaction_amount()); + } + } + } + } + Ok(conservative_bond_delta) } /// Calculates the pool's solvency after opening a short. /// - /// We can express the pool's solvency after opening a short of `$x$` bonds - /// as: + /// We can express the pool's solvency after opening a short of `$\Delta y$` + /// bonds as: /// /// ```math - /// s(x) = z(x) - \tfrac{e(x)}{c} - z_{min} + /// s(\Delta y) = z(\Delta y) - \tfrac{e(\Delta y)}{c} - z_{min} /// ``` /// - /// where `$z(x)$` represents the pool's share reserves after opening the - /// short: + /// where `$z(\Delta y)$` represents the pool's share reserves after opening + /// the short: /// /// ```math - /// z(x) = z_0 - \left( - /// P(x) - \left( \tfrac{c(x)}{c} - \tfrac{g(x)}{c} \right) - /// \right) + /// z(\Delta y) = z_0 - \left( + /// P(\Delta y) - \left( \tfrac{c(\Delta y)}{c} + /// - \tfrac{g(\Delta y)}{c} \right) + /// \right) /// ``` /// - /// and `$e(x)$` represents the pool's exposure after opening the short: + /// and `$e(\Delta y)$` represents the pool's exposure after opening the + /// short: /// /// ```math - /// e(x) = e_0 - min(x + D(x), max(e_{c}, 0)) + /// e(\Delta y) = e_0 - min(\Delta y + D(\Delta y), max(e_{c}, 0)) /// ``` /// - /// We simplify our `$e(x)$` formula by noting that the max short is only - /// constrained by solvency when `$x + D(x) > max(e_{c}, 0)$` since - /// `$x + D(x)$` grows faster than - /// `$P(x) - \tfrac{\phi_{c}}{c} \cdot \left( 1 - p \right) \cdot x$`. - /// With this in mind, `$min(x + D(x), max(e_{c}, 0)) = max(e_{c}, 0)$` - /// whenever solvency is actually a constraint, so we can write: + /// We simplify our `$e(\Delta y)$` formula by noting that the max short is + /// only constrained by solvency when + /// `$\Delta y + D(\Delta y) > max(e_{c}, 0)$` since + /// `$\Delta y + D(\Delta y)$` grows faster than + /// `$P(\Delta y) - \tfrac{\phi_{c}}{c} \cdot \left( 1 - p \right) \cdot \Delta y$`. + /// With this in mind, + /// `$min(\Delta y + D(\Delta y), max(e_{c}, 0)) = max(e_{c}, 0)$` whenever + /// solvency is actually a constraint, so we can write: /// /// ```math - /// e(x) = e_0 - max(e_{c}, 0) + /// e(\Delta y) = e_0 - max(e_{c}, 0) /// ``` fn solvency_after_short( &self, bond_amount: FixedPoint, checkpoint_exposure: I256, ) -> Result> { - let share_delta = self.calculate_pool_share_delta_after_open_short(bond_amount)?; - if self.share_reserves() < share_delta { + let pool_share_delta = self.calculate_pool_share_delta_after_open_short(bond_amount)?; + // If the share reserves would underflow when the short is opened, + // then we revert with an insufficient liquidity error. + if self.share_reserves() < pool_share_delta { return Err(eyre!( - "expected share_reserves={:#?} >= share_delta={:#?}", + "Insufficient liquidity. Expected share_reserves={:#?} >= pool_share_delta={:#?}", self.share_reserves(), - share_delta + pool_share_delta )); } - let new_share_reserves = self.share_reserves() - share_delta; + // Check z - zeta >= z_min. + let new_share_reserves = self.share_reserves() - pool_share_delta; + let new_effective_share_reserves = + calculate_effective_share_reserves(new_share_reserves, self.share_adjustment())?; + if new_effective_share_reserves < self.minimum_share_reserves() { + return Err(eyre!("Insufficient liquidity. Expected effective_share_reserves={:#?} >= min_share_reserves={:#?}", + new_effective_share_reserves, self.minimum_share_reserves())); + } + // Check global exposure, which also checks z >= z_min. let exposure_shares = { let checkpoint_exposure = FixedPoint::try_from(checkpoint_exposure.max(I256::zero()))?; if self.long_exposure() < checkpoint_exposure { @@ -630,13 +719,15 @@ impl State { checkpoint_exposure )); } else { - (self.long_exposure() - checkpoint_exposure) / self.vault_share_price() + // Div up to make the check more conservative. + (self.long_exposure() - checkpoint_exposure).div_up(self.vault_share_price()) } }; if new_share_reserves >= exposure_shares + self.minimum_share_reserves() { Ok(new_share_reserves - exposure_shares - self.minimum_share_reserves()) } else { - Err(eyre!("Short would result in an insolvent pool.")) + Err(eyre!("Insiffucient liquidity. Expected share_reserves={:#?} >= {:#?} = exposure_shares={:#?} + min_share_reserves={:#?}", + new_share_reserves, exposure_shares + self.minimum_share_reserves(), exposure_shares, self.minimum_share_reserves())) } } @@ -647,9 +738,10 @@ impl State { /// /// ```math /// \begin{aligned} - /// s'(x) &= z'(x) - 0 - 0 - /// &= 0 - \left( P'(x) - \frac{(c'(x) - g'(x))}{c} \right) - /// &= -P'(x) + \frac{ + /// s'(\Delta y) &= z'(\Delta y) - 0 - 0 + /// &= 0 - \left( P'(\Delta y) - \frac{(c'(\Delta y) + /// - g'(\Delta y))}{c} \right) + /// &= -P'(\Delta y) + \frac{ /// \phi_{c} \cdot (1 - p) \cdot (1 - \phi_{g}) /// }{c} /// \end{aligned} @@ -671,7 +763,7 @@ impl State { if lhs >= rhs { Ok(lhs - rhs) } else { - Err(eyre!("Invalid derivative.")) + Err(eyre!("Negative derivative.")) } } } @@ -720,7 +812,7 @@ mod tests { // Likely: fix absolute max short such that the output is guaranteed to be solvent. match panic::catch_unwind(|| { state.calculate_absolute_max_short( - state.calculate_spot_price()?, + state.calculate_spot_price_down()?, checkpoint_exposure, Some(max_iterations), ) @@ -791,21 +883,14 @@ mod tests { Ok(()) } - /// This test differentially fuzzes the `calculate_max_short` function against - /// the Solidity analogue `calculateMaxShort`. `calculateMaxShort` doesn't take - /// a trader's budget into account, so it only provides a subset of - /// `calculate_max_short`'s functionality. With this in mind, we provide - /// `calculate_max_short` with a budget of `U256::MAX` to ensure that the two - /// functions are equivalent. + /// Test to ensure that the absolute max short guess is always solvent. #[tokio::test] - async fn fuzz_sol_calculate_max_short_without_budget() -> Result<()> { - // TODO: We should be able to pass these tests with a much lower (if not zero) tolerance. - let sol_correctness_tolerance = fixed!(1e17); + async fn fuzz_calculate_absolute_max_short_guess() -> Result<()> { + let solvency_tolerance = fixed!(100_000_000e18); - // Fuzz the rust and solidity implementations against each other. - let chain = TestChain::new().await?; let mut rng = thread_rng(); for _ in 0..*FAST_FUZZ_RUNS { + // Compute a random state and checkpoint exposure. let state = rng.gen::(); let checkpoint_exposure = { let value = rng.gen_range(fixed!(0)..=FixedPoint::from(U256::from(U128::MAX))); @@ -814,70 +899,39 @@ mod tests { } else { I256::try_from(value)? } - }; - let max_iterations = 7; - // We need to catch panics because of overflows. - let rust_max_bond_amount = panic::catch_unwind(|| { - state.calculate_absolute_max_short( - state.calculate_spot_price()?, - checkpoint_exposure, - Some(max_iterations), - ) - }); - // Run the solidity function & compare outputs. - 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 + } + .min(I256::try_from(state.long_exposure())?); + + let min_share_reserves = state.calculate_min_share_reserves(checkpoint_exposure)?; + + // Check that a short is possible. + if state + .effective_share_reserves()? + .min(state.share_reserves()) + < min_share_reserves { - Ok(sol_max_bond_amount) => { - // Make sure the solidity & rust runctions gave the same value. - let rust_max_bonds_unwrapped = rust_max_bond_amount.unwrap().unwrap(); - let sol_max_bonds_fp = FixedPoint::from(sol_max_bond_amount); - let error = if sol_max_bonds_fp > rust_max_bonds_unwrapped { - sol_max_bonds_fp - rust_max_bonds_unwrapped - } else { - rust_max_bonds_unwrapped - sol_max_bonds_fp - }; - assert!( - error < sol_correctness_tolerance, - "expected abs(solidity_amount={} - rust_amount={})={} < tolerance={}", - sol_max_bonds_fp, - rust_max_bonds_unwrapped, - error, - sol_correctness_tolerance, - ); - } - // Hyperdrive Solidity calculate_max_short threw an error - Err(sol_err) => { - assert!( - rust_max_bond_amount.is_err() - || rust_max_bond_amount.as_ref().unwrap().is_err(), - "expected rust_max_short={:#?} to have an error.\nsolidity error={:#?}", - rust_max_bond_amount, - sol_err - ); - } - }; + continue; + } + match state + .solvency_after_short(state.minimum_transaction_amount(), checkpoint_exposure) + { + Ok(_) => (), + Err(_) => continue, + } + + // Compute the guess, check that it is solvent. + let max_short_guess = state.absolute_max_short_guess(checkpoint_exposure)?; + let solvency = state.solvency_after_short(max_short_guess, checkpoint_exposure)?; + + // Check that the remaining available shares in the pool are below a tolerance. + assert!( + solvency <= solvency_tolerance, + "solvency={:#?} > solvency_tolerance={:#?}", + solvency, + solvency_tolerance + ); } + Ok(()) } @@ -935,8 +989,18 @@ mod tests { .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?)) .await?; + // Check that a short is possible. + if state.effective_share_reserves()? + < state.calculate_min_share_reserves(checkpoint_exposure)? + { + 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( - state.calculate_spot_price()?, + state.calculate_spot_price_down()?, checkpoint_exposure, None, )?; @@ -1044,10 +1108,23 @@ mod tests { .await { 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(checkpoint_exposure)? + { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + bob.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( - state.calculate_spot_price()?, + state.calculate_spot_price_down()?, checkpoint_exposure, Some(max_iterations), ) @@ -1165,7 +1242,7 @@ mod tests { // Solidity reports everything is good, so we run the Rust fns. let rust_max_bonds = panic::catch_unwind(|| { state.calculate_absolute_max_short( - state.calculate_spot_price()?, + state.calculate_spot_price_down()?, checkpoint_exposure, Some(max_iterations), ) diff --git a/crates/hyperdrive-math/src/short/open.rs b/crates/hyperdrive-math/src/short/open.rs index 91552349..17094d5b 100644 --- a/crates/hyperdrive-math/src/short/open.rs +++ b/crates/hyperdrive-math/src/short/open.rs @@ -195,7 +195,7 @@ impl State { let spot_price = match maybe_initial_spot_price { Some(spot_price) => spot_price, - None => self.calculate_spot_price()?, + None => self.calculate_spot_price_down()?, }; // All of these are in base. @@ -274,6 +274,14 @@ impl State { /// the reserves are updated such that /// `state.bond_reserves += bond_amount` and /// `state.share_reserves -= share_amount`. + /// + /// The pool shares delta is the initial amount minus the short principal + /// minus the curve fee, or: + /// ∆z = z0 - z_sp - ø_c, + /// from calculate_pool_share_delta_after_open_short: + /// z1 = z0 - ∆z + /// therefore, + /// z1 = z0 - (z0 - z_sp - ø_c) = z_sp + ø_c pub fn calculate_pool_state_after_open_short( &self, bond_amount: FixedPoint, @@ -289,28 +297,45 @@ impl State { Ok(state) } - /// Calculate the share delta to be applied to the pool after opening a short. + /// Calculates the share delta to be applied to the pool after opening a + /// short. + /// + /// The share delta is given by: + /// + /// ```math + /// \Delta z = + /// P_{\text{lp}}(\Delta y) + /// - \left( \frac{\Phi_{c,os}(\Delta y)}{c} + /// - \frac{\Phi_{g,os}(\Delta y)}{c} \right) + /// ``` + /// + /// Using the definitions of `$P_{\text{lp}}(\Delta y)`$, + /// `$\Phi_{c,os}(\Delta y)$`, and `$\Phi_{g,os}(\Delta y)$`: + /// + /// ```math + /// \Delta z = z + /// - \frac{1}{\mu} \cdot \left( + /// \frac{\mu}{c} \cdot (k - (y + \Delta y)^{1 - t_s}) + /// \right)^{\frac{1}{1 - t_s}} + /// - \frac{\phi_c \cdot (1 - p) \cdot \Delta y \cdot (1 - \phi_g)}{c} + /// ``` pub fn calculate_pool_share_delta_after_open_short( &self, bond_amount: FixedPoint, ) -> Result> { - let curve_fee_base = self.open_short_curve_fee(bond_amount)?; - let curve_fee_shares = curve_fee_base.div_up(self.vault_share_price()); - let gov_curve_fee_shares = self - .open_short_governance_fee(bond_amount, Some(curve_fee_base))? - .div_up(self.vault_share_price()); + let total_fee_shares = self.calculate_open_short_total_fee_shares(bond_amount)?; let short_principal = self.calculate_short_principal(bond_amount)?; if short_principal.mul_up(self.vault_share_price()) > bond_amount { return Err(eyre!("InsufficientLiquidity: Negative Interest")); } - if short_principal < (curve_fee_shares - gov_curve_fee_shares) { + if short_principal < total_fee_shares { return Err(eyre!( "short_principal={:#?} is too low to account for fees={:#?}", short_principal, - curve_fee_shares - gov_curve_fee_shares + total_fee_shares )); } - Ok(short_principal - (curve_fee_shares - gov_curve_fee_shares)) + Ok(short_principal - total_fee_shares) } /// Calculates the spot price after opening a short. @@ -326,7 +351,7 @@ impl State { }; let updated_state = self.calculate_pool_state_after_open_short(bond_amount, Some(share_amount))?; - updated_state.calculate_spot_price() + updated_state.calculate_spot_price_down() } /// Calculate the spot rate after a short has been opened. @@ -455,7 +480,7 @@ impl State { self.calculate_short_principal(self.minimum_transaction_amount())?; let price_adjustment_with_fees = close_vault_share_price / open_vault_share_price + self.flat_fee() - + self.curve_fee() * (fixed!(1e18) - self.calculate_spot_price()?); + + self.curve_fee() * (fixed!(1e18) - self.calculate_spot_price_down()?); let approximate_bond_amount = (self.vault_share_price() / price_adjustment_with_fees) * (shares_deposit + minimum_short_principal); Ok(approximate_bond_amount) @@ -512,6 +537,17 @@ mod tests { let checkpoint_exposure = alice .get_checkpoint_exposure(original_state.to_checkpoint(alice.now().await?)) .await?; + // Check that a short is possible. + if original_state.effective_share_reserves()? + < original_state.calculate_min_share_reserves(checkpoint_exposure)? + { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + bob.reset(Default::default()).await?; + celine.reset(Default::default()).await?; + continue; + } + let max_short_amount = original_state.calculate_max_short( U256::MAX, original_state.vault_share_price(), @@ -597,7 +633,7 @@ mod tests { // We need to catch panics because of overflows. let max_bond_amount = match panic::catch_unwind(|| { state.calculate_absolute_max_short( - state.calculate_spot_price()?, + state.calculate_spot_price_down()?, checkpoint_exposure, None, ) @@ -789,7 +825,7 @@ mod tests { let short_deposit_derivative = state.calculate_open_short_derivative( bond_amount, state.vault_share_price(), - Some(state.calculate_spot_price()?), + Some(state.calculate_spot_price_down()?), )?; // Ensure that the empirical and analytical derivatives match. @@ -839,9 +875,21 @@ mod tests { // Alice initializes the pool. alice.initialize(fixed_rate, contribution, None).await?; - - // Attempt to predict the spot price after opening a short. let mut state = alice.get_state().await?; + + // Check that a short is possible. + let checkpoint_exposure = alice + .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?)) + .await?; + if state.effective_share_reserves()? + < state.calculate_min_share_reserves(checkpoint_exposure)? + { + 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?, ); @@ -860,7 +908,7 @@ mod tests { // Verify that the predicted spot price is equal to the ending spot // price. let expected_spot_price = state.calculate_spot_price_after_short(bond_amount, None)?; - let actual_spot_price = new_state.calculate_spot_price()?; + let actual_spot_price = new_state.calculate_spot_price_down()?; let abs_spot_price_diff = if actual_spot_price >= expected_spot_price { actual_spot_price - expected_spot_price } else { @@ -895,14 +943,18 @@ mod tests { // fails because we want to test the default behavior when the state // allows all actions. let state = rng.gen::(); - let checkpoint_exposure = rng - .gen_range(fixed!(0)..=FixedPoint::::MAX) - .raw() - .flip_sign_if(rng.gen()); + let checkpoint_exposure = { + let value = rng.gen_range(fixed!(0)..=FixedPoint::from(U256::from(U128::MAX))); + if rng.gen() { + -I256::try_from(value)? + } else { + I256::try_from(value)? + } + }; // We need to catch panics because of overflows. let max_bond_amount = match panic::catch_unwind(|| { state.calculate_absolute_max_short( - state.calculate_spot_price()?, + state.calculate_spot_price_down()?, checkpoint_exposure, Some(3), ) @@ -969,6 +1021,20 @@ mod tests { let variable_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(1e18)); alice.advance_time(variable_rate, 12.into()).await?; + // Check that a short is possible. + let state = alice.get_state().await?; + let checkpoint_exposure = alice + .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?)) + .await?; + if state.effective_share_reserves()? + < state.calculate_min_share_reserves(checkpoint_exposure)? + { + 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 // short, we calculate the implied rate. let bond_amount = rng.gen_range( @@ -1062,7 +1128,7 @@ mod tests { // We need to catch panics because of FixedPoint overflows & underflows. let max_trade = panic::catch_unwind(|| { state.calculate_absolute_max_short( - state.calculate_spot_price()?, + state.calculate_spot_price_down()?, checkpoint_exposure, Some(max_iterations), ) @@ -1129,6 +1195,21 @@ mod tests { // Get state and trade details. let mut state = alice.get_state().await?; let min_txn_amount = state.minimum_transaction_amount(); + + // Check that a short is possible. + let checkpoint_exposure = alice + .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?)) + .await?; + if state.effective_share_reserves()? + < state.calculate_min_share_reserves(checkpoint_exposure)? + { + 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?; let bond_amount = rng.gen_range(min_txn_amount..=max_short); diff --git a/crates/hyperdrive-math/src/test_utils/agent.rs b/crates/hyperdrive-math/src/test_utils/agent.rs index c087582a..85b31eb2 100644 --- a/crates/hyperdrive-math/src/test_utils/agent.rs +++ b/crates/hyperdrive-math/src/test_utils/agent.rs @@ -98,7 +98,7 @@ impl HyperdriveMathAgent for Agent, ChaCha8Rng> { } /// Calculates the spot price. async fn calculate_spot_price(&self) -> Result> { - self.get_state().await?.calculate_spot_price() + self.get_state().await?.calculate_spot_price_down() } /// Calculates the long amount that will be opened for a given base amount. @@ -190,7 +190,7 @@ impl HyperdriveMathAgent for Agent, ChaCha8Rng> { // 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()?; + let spot_price = state.calculate_spot_price_down()?; let min_price = state.calculate_min_spot_price()?; // Calculate the linear interpolation. diff --git a/crates/hyperdrive-math/src/test_utils/preamble.rs b/crates/hyperdrive-math/src/test_utils/preamble.rs index 4c26df07..b06b670a 100644 --- a/crates/hyperdrive-math/src/test_utils/preamble.rs +++ b/crates/hyperdrive-math/src/test_utils/preamble.rs @@ -72,8 +72,11 @@ pub async fn initialize_pool_with_random_state( // 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)); - alice.fund(liquidity_amount).await?; - alice.add_liquidity(liquidity_amount, None).await?; + let exposure = alice.get_state().await?.long_exposure(); + alice.fund(liquidity_amount + exposure).await?; + alice + .add_liquidity(liquidity_amount + exposure, None) + .await?; Ok(()) } @@ -87,13 +90,13 @@ fn get_max_long(state: State, maybe_max_num_tries: Option) -> Result match max_long_no_panic { Ok(max_long_no_err) => max_long_no_err, - Err(_) => state.bond_reserves() * state.calculate_spot_price()? * fixed!(10e18), + Err(_) => state.bond_reserves() * state.calculate_spot_price_down()? * fixed!(10e18), }, - Err(_) => state.bond_reserves() * state.calculate_spot_price()? * fixed!(10e18), + Err(_) => state.bond_reserves() * state.calculate_spot_price_down()? * fixed!(10e18), }; let mut num_tries = 0; @@ -143,7 +146,7 @@ pub fn get_max_short( // 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()?; + let spot_price = state.calculate_spot_price_down()?; let min_price = state.calculate_min_spot_price()?; // Calculate the linear interpolation. @@ -158,7 +161,7 @@ pub fn get_max_short( state.vault_share_price(), checkpoint_exposure, Some(conservative_price), - Some(3), + Some(5), ) }) { Ok(max_short_no_panic) => match max_short_no_panic { @@ -274,8 +277,24 @@ mod tests { // Run the preamble. initialize_pool_with_random_state(&mut rng, &mut alice, &mut bob, &mut celine).await?; - // Get state and trade details, then open the max short. + // Get state and trade details. let state = alice.get_state().await?; + let checkpoint_exposure = alice + .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?)) + .await?; + + // Check that a short is possible. + if state.effective_share_reserves()? + < state.calculate_min_share_reserves(checkpoint_exposure)? + { + chain.revert(id).await?; + alice.reset(Default::default()).await?; + bob.reset(Default::default()).await?; + celine.reset(Default::default()).await?; + continue; + } + + // Open the max short. let max_short = bob.calculate_max_short(None).await?; assert!(max_short >= state.minimum_transaction_amount()); bob.fund(max_short + fixed!(10e18)).await?; diff --git a/crates/hyperdrive-math/src/yield_space.rs b/crates/hyperdrive-math/src/yield_space.rs index 0fff5bf9..9e3cff14 100644 --- a/crates/hyperdrive-math/src/yield_space.rs +++ b/crates/hyperdrive-math/src/yield_space.rs @@ -28,14 +28,22 @@ pub trait YieldSpace { /// The YieldSpace time parameter. fn t(&self) -> FixedPoint; - // The current spot price ignoring slippage. - fn calculate_spot_price(&self) -> Result> { + // The current spot price ignoring slippage, rounded down. + fn calculate_spot_price_down(&self) -> Result> { if self.y() <= fixed!(0) { return Err(eyre!("expected y={} > 0", self.y())); } ((self.mu() * self.ze()?) / self.y()).pow(self.t()) } + // The current spot price ignoring slippage, rounded up. + fn calculate_spot_price_up(&self) -> Result> { + if self.y() <= fixed!(0) { + return Err(eyre!("expected y={} > 0", self.y())); + } + ((self.mu().mul_up(self.ze()?)).div_up(self.y())).pow(self.t()) + } + /// Calculates the amount of bonds a user will receive from the pool by /// providing a specified amount of shares. We underestimate the amount of /// bonds out to prevent sandwiches.