hyperdrive_math/short/
max.rs

1use ethers::types::{I256, U256};
2use eyre::{eyre, Result};
3use fixedpointmath::{fixed, FixedPoint};
4
5use crate::{calculate_effective_share_reserves, State, YieldSpace};
6
7impl State {
8    /// Calculates the minimum price that the pool can support.
9    ///
10    /// YieldSpace intersects the y-axis with a finite slope, so there is a
11    /// minimum price that the pool can support. This is the price at which the
12    /// share reserves are equal to the minimum share reserves.
13    ///
14    /// We can solve for the bond reserves `$y_{\text{max}}$` implied by the share reserves
15    /// being equal to `$z_{\text{min}}$` using the current k value:
16    ///
17    /// ```math
18    /// k = \tfrac{c}{\mu} \cdot \left( \mu \cdot z_{min} \right)^{1 - t_s}
19    /// + y_{max}^{1 - t_s} \\
20    /// \implies \\
21    /// y_{max} = \left( k - \tfrac{c}{\mu} \cdot \left(
22    /// \mu \cdot z_{min} \right)^{1 - t_s} \right)^{\tfrac{1}{1 - t_s}}
23    /// ```
24    ///
25    /// From there, we can calculate the spot price as normal as:
26    ///
27    /// ```math
28    /// p = \left( \tfrac{\mu \cdot z_{min}}{y_{max}} \right)^{t_s}
29    /// ```
30    pub fn calculate_min_spot_price(&self) -> Result<FixedPoint<U256>> {
31        let y_max = (self.k_up()?
32            - (self.vault_share_price() / self.initial_vault_share_price())
33                * (self.initial_vault_share_price() * self.minimum_share_reserves())
34                    .pow(fixed!(1e18) - self.time_stretch())?)
35        .pow(fixed!(1e18).div_up(fixed!(1e18) - self.time_stretch()))?;
36
37        ((self.initial_vault_share_price() * self.minimum_share_reserves()) / y_max)
38            .pow(self.time_stretch())
39    }
40
41    /// Use Newton's method with rate reduction to find the amount of bonds
42    /// shorted for a given base deposit amount.
43    pub fn calculate_short_bonds_given_deposit(
44        &self,
45        target_base_amount: FixedPoint<U256>,
46        open_vault_share_price: FixedPoint<U256>,
47        absolute_max_bond_amount: FixedPoint<U256>,
48        maybe_tolerance: Option<FixedPoint<U256>>,
49        maybe_max_iterations: Option<usize>,
50    ) -> Result<FixedPoint<U256>> {
51        let tolerance = maybe_tolerance.unwrap_or(fixed!(1e9));
52        let max_iterations = maybe_max_iterations.unwrap_or(500);
53
54        // The max bond amount might be below the pool's minimum.
55        // If so, no short can be opened.
56        if absolute_max_bond_amount < self.minimum_transaction_amount() {
57            return Err(eyre!("No solvent short is possible."));
58        }
59
60        // Figure out the base deposit for the absolute max bond amount.
61        let absolute_max_base_amount =
62            self.calculate_open_short(absolute_max_bond_amount, open_vault_share_price)?;
63        if target_base_amount > absolute_max_base_amount {
64            return Err(eyre!(
65                "Input too large.
66                target_base_amount={:#?}
67                max_base_amount   ={:#?}",
68                target_base_amount,
69                absolute_max_base_amount
70            ));
71        }
72        // If the absolute max is within tolerance, return it.
73        if absolute_max_base_amount - target_base_amount <= tolerance {
74            return Ok(absolute_max_bond_amount);
75        }
76
77        // Compute a conservative estimate of the bonds shorted & base paid.
78        let mut last_good_bond_amount = self.calculate_approximate_short_bonds_given_base_deposit(
79            target_base_amount,
80            open_vault_share_price,
81        )?;
82
83        // Run Newton's Method to adjust the bond amount.
84        let mut loss = FixedPoint::from(U256::MAX);
85        for _ in 0..max_iterations {
86            // Calculate the current deposit.
87            let current_base_amount =
88                self.calculate_open_short(last_good_bond_amount, open_vault_share_price)?;
89
90            // Calculate the current loss.
91            loss = if current_base_amount < target_base_amount {
92                // It's possible that a nudge from failure cases in the previous
93                // iteration put us within the tolerance.
94                if (target_base_amount - current_base_amount) <= tolerance {
95                    return Ok(last_good_bond_amount);
96                }
97                target_base_amount - current_base_amount
98            } else {
99                current_base_amount - target_base_amount
100            };
101
102            // Calculate the update amount.
103            let base_amount_derivative = self.calculate_open_short_derivative(
104                last_good_bond_amount,
105                open_vault_share_price,
106                Some(self.calculate_spot_price()?),
107            )?;
108            let dy = loss.div_up(base_amount_derivative); // div up to discourage dy == 0
109
110            // Calculate the new bond amount.
111            // The update rule is: y_1 = y_0 - L(x, x_t) / dL(x, x_t)/dy,
112            // where y is the bond amount, x is the base deposit returned by
113            // calculate_open_short(y), x_t is the target deposit, L is the loss
114            // (x_t - x), and dL(x, x_t)/dy = -base_amount_derivative. We avoid
115            // negative numbers using a conditional.
116            let new_bond_amount = if current_base_amount < target_base_amount {
117                last_good_bond_amount + dy
118            } else {
119                last_good_bond_amount - dy
120            };
121
122            // Check solvency with the latest bond amount.
123            match self.calculate_open_short(new_bond_amount, open_vault_share_price) {
124                Ok(new_base_amount) => {
125                    if new_base_amount <= target_base_amount {
126                        last_good_bond_amount = new_bond_amount;
127                    }
128                    // We overshot the zero crossing & the amount was solvent.
129                    // It's possible that we are so close in base amounts that
130                    // dy is zero, but we are still overshooting. In this case,
131                    // nudge the bond amount down by the error and continue.
132                    else {
133                        let error = new_base_amount - target_base_amount;
134                        last_good_bond_amount = new_bond_amount - error;
135                    }
136                }
137                // New bond amount is not solvent. Start again from slightly
138                // below the absolute max.
139                Err(_) => {
140                    // We know abs max is solvent and we know the target bond
141                    // amount is less than the absolute max. So we overshot, but
142                    // we can safely overshoot by less.
143                    if new_bond_amount >= absolute_max_bond_amount {
144                        last_good_bond_amount = absolute_max_bond_amount - tolerance;
145                    } else {
146                        return Err(eyre!(
147                            "current_bond_amount={:#?} is less than the expected absolute_max={:#?}, but still not solvent.",
148                            new_bond_amount,
149                            absolute_max_bond_amount
150                        ));
151                    }
152                }
153            }
154        }
155        // Did not hit tolerance in max iterations.
156        return Err(eyre!(
157            "Could not converge to a bond amount given max iterations = {:#?}.
158            Target base deposit = {:#?}
159            Error               = {:#?}
160            Tolerance           = {:#?}",
161            max_iterations,
162            target_base_amount,
163            loss,
164            tolerance,
165        ));
166    }
167
168    // TODO: Make it clear to the consumer that the maximum number of iterations
169    // is 2 * max_iterations.
170    //
171    /// Calculates the max short that can be opened with the given budget.
172    ///
173    /// We start by finding the largest possible short (irrespective of budget),
174    /// and then we iteratively approach a solution using Newton's method if the
175    /// budget isn't satisified.
176    ///
177    /// The user can provide `maybe_conservative_price`, which is a lower bound
178    /// on the realized price that the short will pay. This is used to help the
179    /// algorithm converge faster in real world situations. If this is `None`,
180    /// then we'll use the theoretical worst case realized price.
181    pub fn calculate_max_short<
182        F1: Into<FixedPoint<U256>>,
183        F2: Into<FixedPoint<U256>>,
184        I: Into<I256>,
185    >(
186        &self,
187        budget: F1,
188        open_vault_share_price: F2,
189        checkpoint_exposure: I,
190        maybe_conservative_price: Option<FixedPoint<U256>>, // TODO: Is there a nice way of abstracting the inner type?
191        maybe_max_iterations: Option<usize>,
192    ) -> Result<FixedPoint<U256>> {
193        let budget = budget.into();
194        let open_vault_share_price = open_vault_share_price.into();
195        let checkpoint_exposure = checkpoint_exposure.into();
196
197        // Sanity check that we can open any shorts at all.
198        if self
199            .solvency_after_short(self.minimum_transaction_amount(), checkpoint_exposure)
200            .is_err()
201        {
202            return Err(eyre!("No solvent short is possible."));
203        }
204
205        // To avoid the case where Newton's method overshoots and stays on
206        // the invalid side of the optimization equation (i.e., when deposit > budget),
207        // we artificially set the target budget to be less than the actual budget.
208        //
209        // If the budget is less than the minimum transaction amount, then we return early.
210        let target_budget = if budget < self.minimum_transaction_amount() {
211            return Err(eyre!(
212                "expected budget={} >= min_transaction_amount={}",
213                budget,
214                self.minimum_transaction_amount(),
215            ));
216        }
217        // If the budget equals the minimum transaction amount, then we return.
218        // We know it is ok because we already checked solvency after opening a
219        // short with the minimum txn amount.
220        else if budget == self.minimum_transaction_amount() {
221            return Ok(self.minimum_transaction_amount());
222        }
223        // If the budget is greater than the minimum transaction amount, then we set the target budget.
224        else {
225            budget - self.minimum_transaction_amount()
226        };
227
228        // If the open share price is zero, then we'll use the current share
229        // price since the checkpoint hasn't been minted yet.
230        let open_vault_share_price = if open_vault_share_price != fixed!(0) {
231            open_vault_share_price
232        } else {
233            self.vault_share_price()
234        };
235
236        // Assuming the budget is infinite, find the largest possible short that
237        // can be opened. If the short satisfies the budget, this is the max
238        // short amount.
239        let spot_price = self.calculate_spot_price()?;
240        // The initial guess should be guaranteed correct, and we should only get better from there.
241        let absolute_max_bond_amount = self.calculate_absolute_max_short(
242            spot_price,
243            checkpoint_exposure,
244            maybe_max_iterations,
245        )?;
246        // The max bond amount might be below the pool's minimum. If so, no short can be opened.
247        if absolute_max_bond_amount < self.minimum_transaction_amount() {
248            return Err(eyre!("No solvent short is possible."));
249        }
250
251        // Figure out the base deposit for the absolute max bond amount.
252        let absolute_max_deposit =
253            self.calculate_open_short(absolute_max_bond_amount, open_vault_share_price)?;
254        if absolute_max_deposit <= target_budget {
255            return Ok(absolute_max_bond_amount);
256        }
257
258        // Make an initial guess to refine.
259        let mut max_bond_amount = self
260            .max_short_guess(
261                target_budget,
262                spot_price,
263                open_vault_share_price,
264                maybe_conservative_price,
265            )
266            .max(self.minimum_transaction_amount());
267        let mut best_valid_max_bond_amount =
268            match self.solvency_after_short(max_bond_amount, checkpoint_exposure) {
269                Ok(_) => max_bond_amount,
270                Err(_) => self.minimum_transaction_amount(),
271            };
272
273        // Use Newton's method to iteratively approach a solution. We use the
274        // short deposit in base minus the budget as our objective function,
275        // which will converge to the amount of bonds that need to be shorted
276        // for the short deposit to consume the entire budget. Using the
277        // notation from the function comments, we can write our objective
278        // function as:
279        //
280        // ```math
281        // F(x) = B - D(x)
282        // ```
283        //
284        // Since `$B$` is just a constant, `$F'(x) = -D'(x)$`. Given the current guess
285        // of `$x_n$`, Newton's method gives us an updated guess of `$x_{n+1}$`:
286        //
287        // ```math
288        // \begin{aligned}
289        // x_{n+1} &= x_n - \tfrac{F(x_n)}{F'(x_n)} \\
290        // &= x_n + \tfrac{B - D(x_n)}{D'(x_n)}
291        // \end{aligned}
292        // ```
293        //
294        // The guess that we make is very important in determining how quickly
295        // we converge to the solution.
296        //
297        // TODO: This can get stuck in a loop if the Newton update pushes the bond amount to be too large.
298        for _ in 0..maybe_max_iterations.unwrap_or(7) {
299            let deposit = match self.calculate_open_short(max_bond_amount, open_vault_share_price) {
300                Ok(valid_deposit) => valid_deposit,
301                Err(_) => {
302                    // The pool is insolvent for the guess at this point.
303                    // We use the absolute max bond amount and deposit
304                    // for the next guess iteration
305                    max_bond_amount = best_valid_max_bond_amount;
306                    // Should work this time.
307                    self.calculate_open_short(max_bond_amount, open_vault_share_price)?
308                }
309            };
310
311            // We update the best valid max bond amount if the deposit amount
312            // is valid and the current guess is bigger than the previous best.
313            if deposit <= target_budget && max_bond_amount >= best_valid_max_bond_amount {
314                best_valid_max_bond_amount = max_bond_amount;
315                // Stop if we found the exact solution.
316                if deposit == target_budget {
317                    break;
318                }
319            }
320
321            // Iteratively update max_bond_amount via Newton's method.
322            let derivative = self.calculate_open_short_derivative(
323                max_bond_amount,
324                open_vault_share_price,
325                Some(spot_price),
326            )?;
327            if deposit < target_budget {
328                max_bond_amount += (target_budget - deposit) / derivative
329            }
330            // deposit > target_budget
331            else {
332                max_bond_amount -= (deposit - target_budget) / derivative
333            }
334
335            // TODO this always iterates for max_iterations unless
336            // it makes the pool insolvent. Likely want to check an
337            // epsilon to early break
338        }
339
340        // Verify that the max short satisfies the budget.
341        if target_budget
342            < self.calculate_open_short(best_valid_max_bond_amount, open_vault_share_price)?
343        {
344            return Err(eyre!("max short exceeded budget"));
345        }
346
347        // Ensure that the max bond amount is within the absolute max bond amount.
348        if best_valid_max_bond_amount > absolute_max_bond_amount {
349            return Err(eyre!(
350                "max short bond amount exceeded absolute max bond amount"
351            ));
352        }
353
354        Ok(best_valid_max_bond_amount)
355    }
356
357    /// Calculates an initial guess for the max short calculation.
358    ///
359    /// The user can specify a conservative price that they know is less than
360    /// the worst-case realized price. This significantly improves the speed of
361    /// convergence of Newton's method.
362    fn max_short_guess(
363        &self,
364        budget: FixedPoint<U256>,
365        spot_price: FixedPoint<U256>,
366        open_vault_share_price: FixedPoint<U256>,
367        maybe_conservative_price: Option<FixedPoint<U256>>,
368    ) -> FixedPoint<U256> {
369        // If a conservative price is given, we can use it to solve for an
370        // initial guess for what the max short is. If this conservative price
371        // is an overestimate or if a conservative price isn't given, we revert
372        // to using the theoretical worst case scenario as our guess.
373        if let Some(conservative_price) = maybe_conservative_price {
374            // Given our conservative price `$p_c$`, we can write the short deposit
375            // function as:
376            //
377            // ```math
378            // D(x) = \left( \tfrac{c}{c_0} - $p_c$ \right) \cdot x
379            //        + \phi_{flat} \cdot x + \phi_{curve} \cdot (1 - p) \cdot x
380            // ```
381            //
382            // We then solve for $x^*$ such that $D(x^*) = B$, which gives us a
383            // guess of:
384            //
385            // ```math
386            // x^* = \tfrac{B}{\tfrac{c}{c_0} - $p_c$ + \phi_{flat}
387            // + \phi_{curve} \cdot (1 - p)}
388            // ```
389            //
390            // If the budget can cover the actual short deposit on `$x^*$`, we
391            // return it as our guess. Otherwise, we revert to the worst case
392            // scenario.
393            let guess = budget
394                / (self.vault_share_price().div_up(open_vault_share_price)
395                    + self.flat_fee()
396                    + self.curve_fee() * (fixed!(1e18) - spot_price)
397                    - conservative_price);
398            if let Ok(deposit) = self.calculate_open_short(guess, open_vault_share_price) {
399                if budget >= deposit {
400                    return guess;
401                }
402            }
403        }
404
405        // We know that the max short's bond amount is greater than 0 which
406        // gives us an absolute lower bound, but we can do better most of the
407        // time. If the fixed rate was infinite, the max loss for shorts would
408        // be 1 per bond since the spot price would be 0. With this in mind, the
409        // max short amount would be equal to the budget before we consider the
410        // flat fee, curve fee, and back-paid interest. Considering that the
411        // budget also needs to cover the fees and back-paid interest, we
412        // subtract these components from the budget to get a better estimate of
413        // the max bond amount. If subtracting these components results in a
414        // negative number, we just 0 as our initial guess.
415        let worst_case_deposit = match self.calculate_open_short(budget, open_vault_share_price) {
416            Ok(d) => d,
417            Err(_) => return fixed!(0),
418        };
419        if budget >= worst_case_deposit {
420            budget - worst_case_deposit
421        } else {
422            fixed!(0)
423        }
424    }
425
426    /// Calculates the max short that can be opened on the YieldSpace curve
427    /// without considering solvency constraints.
428    fn calculate_max_short_upper_bound(&self) -> Result<FixedPoint<U256>> {
429        // We have the twin constraints that $z \geq z_{min}$ and
430        // $z - \zeta \geq z_{min}$. Combining these together, we calculate
431        // the optimal share reserves as $z_{optimal} = z_{min} + max(0, \zeta)$.
432        let optimal_share_reserves = self.minimum_share_reserves()
433            + FixedPoint::try_from(self.share_adjustment().max(I256::zero()))?;
434
435        // We calculate the optimal bond reserves by solving for the bond
436        // reserves that is implied by the optimal share reserves. We can do
437        // this as follows:
438        //
439        // k = (c / mu) * (mu * (z' - zeta)) ** (1 - t_s) + y' ** (1 - t_s)
440        //                              =>
441        // y' = (k - (c / mu) * (mu * (z' - zeta)) ** (1 - t_s)) ** (1 / (1 - t_s))
442        let optimal_effective_share_reserves =
443            calculate_effective_share_reserves(optimal_share_reserves, self.share_adjustment())?;
444        let optimal_bond_reserves = self.k_down()?
445            - self.vault_share_price().mul_div_up(
446                self.initial_vault_share_price()
447                    .mul_up(optimal_effective_share_reserves)
448                    .pow(fixed!(1e18) - self.time_stretch())?,
449                self.initial_vault_share_price(),
450            );
451        let optimal_bond_reserves = if optimal_bond_reserves >= fixed!(1e18) {
452            // Rounding the exponent down results in a smaller outcome.
453            optimal_bond_reserves.pow(fixed!(1e18).div_down(fixed!(1e18) - self.time_stretch()))?
454        } else {
455            // Rounding the exponent up results in a smaller outcome.
456            optimal_bond_reserves.pow(fixed!(1e18).div_up(fixed!(1e18) - self.time_stretch()))?
457        };
458
459        Ok(optimal_bond_reserves - self.bond_reserves())
460    }
461
462    /// Calculates the absolute max short that can be opened without violating the
463    /// pool's solvency constraints.
464    pub fn calculate_absolute_max_short(
465        &self,
466        spot_price: FixedPoint<U256>,
467        checkpoint_exposure: I256,
468        maybe_max_iterations: Option<usize>,
469    ) -> Result<FixedPoint<U256>> {
470        // We start by calculating the maximum short that can be opened on the
471        // YieldSpace curve.
472        let absolute_max_bond_amount = self.calculate_max_short_upper_bound()?;
473        if self
474            .solvency_after_short(absolute_max_bond_amount, checkpoint_exposure)
475            .is_ok()
476        {
477            return Ok(absolute_max_bond_amount);
478        }
479
480        // Use Newton's method to iteratively approach a solution. We use pool's
481        // solvency $S(x)$ w.r.t. the amount of bonds shorted $x$ as our
482        // objective function, which will converge to the maximum short amount
483        // when $S(x) = 0$. The derivative of $S(x)$ is negative (since solvency
484        // decreases as more shorts are opened). The fixed point library doesn't
485        // support negative numbers, so we use the negation of the derivative to
486        // side-step the issue.
487        //
488        // Given the current guess of $x_n$, Newton's method gives us an updated
489        // guess of $x_{n+1}$:
490        //
491        // ```math
492        // \begin{aligned}
493        // x_{n+1} &= x_n - \tfrac{S(x_n)}{S'(x_n)} \\
494        // &= x_n + \tfrac{S(x_n)}{-S'(x_n)}
495        // \end{aligned}
496        // ```
497        //
498        // The guess that we make is very important in determining how quickly
499        // we converge to the solution.
500        let mut max_bond_guess = self.absolute_max_short_guess(spot_price, checkpoint_exposure)?;
501        // If the initial guess is insolvent, we need to throw an error.
502        let mut solvency = self.solvency_after_short(max_bond_guess, checkpoint_exposure)?;
503        for _ in 0..maybe_max_iterations.unwrap_or(7) {
504            // TODO: It may be better to gracefully handle crossing over the
505            // root by extending the fixed point math library to handle negative
506            // numbers or even just using an if-statement to handle the negative
507            // numbers.
508            //
509            // Calculate the next iteration of Newton's method. If the candidate
510            // is larger than the absolute max, we've gone too far and something
511            // has gone wrong.
512            let derivative = match self.solvency_after_short_derivative(max_bond_guess, spot_price)
513            {
514                Ok(derivative) => derivative,
515                Err(_) => break,
516            };
517            let possible_max_bond_amount = max_bond_guess + solvency / derivative;
518            if possible_max_bond_amount > absolute_max_bond_amount {
519                break;
520            }
521
522            // If the candidate is insolvent, we've gone too far and can stop
523            // iterating. Otherwise, we update our guess and continue.
524            solvency =
525                match self.solvency_after_short(possible_max_bond_amount, checkpoint_exposure) {
526                    Ok(solvency) => {
527                        max_bond_guess = possible_max_bond_amount;
528                        solvency
529                    }
530                    Err(_) => break,
531                };
532        }
533
534        Ok(max_bond_guess)
535    }
536
537    /// Calculates an initial guess for the absolute max short. This is a conservative
538    /// guess that will be less than the true absolute max short, which is what
539    /// we need to start Newton's method.
540    ///
541    /// To calculate our guess, we assume an unrealistically good realized
542    /// price `$p_r$` for opening the short. This allows us to approximate
543    /// `$P(x) \approx \tfrac{1}{c} \cdot p_r \cdot x$`. Plugging this
544    /// into our solvency function `$S(x)$`, we get an approximation of our
545    /// solvency as:
546    ///
547    /// ```math
548    /// S(x) \approx (z_0 - \tfrac{1}{c} \cdot (
549    ///                  p_r - \phi_{c} \cdot (1 - p) + \phi_{g} \cdot \phi_{c} \cdot (1 - p)
550    ///              )) - \tfrac{e_0 - max(e_{c}, 0)}{c} - z_{min}
551    /// ```
552    ///
553    /// Setting this equal to zero, we can solve for our initial guess:
554    ///
555    /// ```math
556    /// x = \frac{c \cdot (s_0 + \tfrac{max(e_{c}, 0)}{c})}{
557    ///         p_r - \phi_{c} \cdot (1 - p) + \phi_{g} \cdot \phi_{c} \cdot (1 - p)
558    ///     }
559    /// ```
560    fn absolute_max_short_guess(
561        &self,
562        spot_price: FixedPoint<U256>,
563        checkpoint_exposure: I256,
564    ) -> Result<FixedPoint<U256>> {
565        let checkpoint_exposure_shares =
566            FixedPoint::try_from(checkpoint_exposure.max(I256::zero()))?
567                .div_down(self.vault_share_price());
568        // solvency = share_reserves - long_exposure / vault_share_price - min_share_reserves
569        let solvency = self.calculate_solvency()? + checkpoint_exposure_shares;
570        let guess = self.vault_share_price().mul_down(solvency);
571        let curve_fee = self.curve_fee().mul_down(fixed!(1e18) - spot_price);
572        let gov_curve_fee = self.governance_lp_fee().mul_down(curve_fee);
573        Ok(guess.div_down(spot_price - curve_fee + gov_curve_fee))
574    }
575
576    /// Calculates the pool's solvency after opening a short.
577    ///
578    /// We can express the pool's solvency after opening a short of `$x$` bonds
579    /// as:
580    ///
581    /// ```math
582    /// s(x) = z(x) - \tfrac{e(x)}{c} - z_{min}
583    /// ```
584    ///
585    /// where `$z(x)$` represents the pool's share reserves after opening the
586    /// short:
587    ///
588    /// ```math
589    /// z(x) = z_0 - \left(
590    ///            P(x) - \left( \tfrac{c(x)}{c} - \tfrac{g(x)}{c} \right)
591    ///        \right)
592    /// ```
593    ///
594    /// and `$e(x)$` represents the pool's exposure after opening the short:
595    ///
596    /// ```math
597    /// e(x) = e_0 - min(x + D(x), max(e_{c}, 0))
598    /// ```
599    ///
600    /// We simplify our `$e(x)$` formula by noting that the max short is only
601    /// constrained by solvency when `$x + D(x) > max(e_{c}, 0)$` since
602    /// `$x + D(x)$` grows faster than
603    /// `$P(x) - \tfrac{\phi_{c}}{c} \cdot \left( 1 - p \right) \cdot x$`.
604    /// With this in mind, `$min(x + D(x), max(e_{c}, 0)) = max(e_{c}, 0)$`
605    /// whenever solvency is actually a constraint, so we can write:
606    ///
607    /// ```math
608    /// e(x) = e_0 - max(e_{c}, 0)
609    /// ```
610    fn solvency_after_short(
611        &self,
612        bond_amount: FixedPoint<U256>,
613        checkpoint_exposure: I256,
614    ) -> Result<FixedPoint<U256>> {
615        let share_delta = self.calculate_pool_share_delta_after_open_short(bond_amount)?;
616        if self.share_reserves() < share_delta {
617            return Err(eyre!(
618                "expected share_reserves={:#?} >= share_delta={:#?}",
619                self.share_reserves(),
620                share_delta
621            ));
622        }
623        let new_share_reserves = self.share_reserves() - share_delta;
624        let exposure_shares = {
625            let checkpoint_exposure = FixedPoint::try_from(checkpoint_exposure.max(I256::zero()))?;
626            if self.long_exposure() < checkpoint_exposure {
627                return Err(eyre!(
628                    "expected long_exposure={:#?} >= checkpoint_exposure={:#?}.",
629                    self.long_exposure(),
630                    checkpoint_exposure
631                ));
632            } else {
633                (self.long_exposure() - checkpoint_exposure) / self.vault_share_price()
634            }
635        };
636        if new_share_reserves >= exposure_shares + self.minimum_share_reserves() {
637            Ok(new_share_reserves - exposure_shares - self.minimum_share_reserves())
638        } else {
639            Err(eyre!("Short would result in an insolvent pool."))
640        }
641    }
642
643    /// Calculates the derivative of the pool's solvency w.r.t. the short
644    /// amount.
645    ///
646    /// The derivative is calculated as:
647    ///
648    /// ```math
649    /// \begin{aligned}
650    /// s'(x) &= z'(x) - 0 - 0
651    ///       &= 0 - \left( P'(x) - \frac{(c'(x) - g'(x))}{c} \right)
652    ///       &= -P'(x) + \frac{
653    ///              \phi_{c} \cdot (1 - p) \cdot (1 - \phi_{g})
654    ///          }{c}
655    /// \end{aligned}
656    /// ```
657    ///
658    /// Since solvency decreases as the short amount increases, we negate the
659    /// derivative. This avoids issues with the fixed point library which
660    /// doesn't support negative values.
661    fn solvency_after_short_derivative(
662        &self,
663        bond_amount: FixedPoint<U256>,
664        spot_price: FixedPoint<U256>,
665    ) -> Result<FixedPoint<U256>> {
666        let lhs = self.calculate_short_principal_derivative(bond_amount)?;
667        let rhs = self.curve_fee()
668            * (fixed!(1e18) - spot_price)
669            * (fixed!(1e18) - self.governance_lp_fee())
670            / self.vault_share_price();
671        if lhs >= rhs {
672            Ok(lhs - rhs)
673        } else {
674            Err(eyre!("Invalid derivative."))
675        }
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use std::panic;
682
683    use ethers::types::{U128, U256};
684    use fixedpointmath::{fixed, uint256};
685    use hyperdrive_test_utils::{
686        chain::TestChain,
687        constants::{FAST_FUZZ_RUNS, FUZZ_RUNS, SLOW_FUZZ_RUNS},
688    };
689    use hyperdrive_wrappers::wrappers::{
690        ihyperdrive::{Checkpoint, Options},
691        mock_hyperdrive_math::MaxTradeParams,
692    };
693    use rand::{thread_rng, Rng, SeedableRng};
694    use rand_chacha::ChaCha8Rng;
695
696    use super::*;
697    use crate::test_utils::{
698        agent::HyperdriveMathAgent,
699        preamble::{get_max_short, initialize_pool_with_random_state},
700    };
701
702    #[tokio::test]
703    async fn fuzz_calculate_short_bonds_given_deposit() -> Result<()> {
704        let test_tolerance = fixed!(1e9);
705        let max_iterations = 500;
706        let mut rng = thread_rng();
707        for _ in 0..*FUZZ_RUNS {
708            let state = rng.gen::<State>();
709            let checkpoint_exposure = {
710                let value = rng.gen_range(fixed!(0)..=FixedPoint::from(U256::from(U128::MAX)));
711                if rng.gen() {
712                    -I256::try_from(value)?
713                } else {
714                    I256::try_from(value)?
715                }
716            };
717            // TODO: Absolute max doesn't always work. Sometimes the random
718            // state causes an overflow when calculating absolute max.
719            // Unlikely: fix the state generator so that the random state always has a valid max.
720            // Likely: fix absolute max short such that the output is guaranteed to be solvent.
721            match panic::catch_unwind(|| {
722                state.calculate_absolute_max_short(
723                    state.calculate_spot_price()?,
724                    checkpoint_exposure,
725                    Some(max_iterations),
726                )
727            }) {
728                Ok(max_bond_no_panic) => {
729                    match max_bond_no_panic {
730                        Ok(absolute_max_bond_amount) => {
731                            // Get random input parameters.
732                            let open_vault_share_price =
733                                rng.gen_range(fixed!(1e5)..=state.vault_share_price());
734                            match panic::catch_unwind(|| {
735                                state.calculate_open_short(
736                                    absolute_max_bond_amount,
737                                    open_vault_share_price,
738                                )
739                            }) {
740                                Ok(result_no_panic) => match result_no_panic {
741                                    Ok(_) => (),
742                                    Err(_) => continue,
743                                },
744                                Err(_) => continue,
745                            }
746                            let max_short_bonds =
747                                match get_max_short(state.clone(), checkpoint_exposure, None) {
748                                    Ok(max_short_trade) => {
749                                        max_short_trade.min(absolute_max_bond_amount)
750                                    }
751                                    Err(_) => continue,
752                                };
753                            let max_short_base = state
754                                .calculate_open_short(max_short_bonds, open_vault_share_price)?;
755                            let target_base_amount =
756                                rng.gen_range(state.minimum_transaction_amount()..=max_short_base);
757                            // Run the function to be tested.
758                            let bond_amount = state.calculate_short_bonds_given_deposit(
759                                target_base_amount,
760                                open_vault_share_price,
761                                absolute_max_bond_amount,
762                                Some(test_tolerance),
763                                Some(max_iterations),
764                            )?;
765                            // Verify outputs.
766                            let computed_base_amount =
767                                state.calculate_open_short(bond_amount, open_vault_share_price)?;
768                            assert!(
769                                target_base_amount >= computed_base_amount,
770                                "target is less than computed base amount:
771                                target_base_amount  ={:#?}
772                                computed_base_amount={:#?}",
773                                target_base_amount,
774                                computed_base_amount
775                            );
776                            assert!(
777                                target_base_amount - computed_base_amount <= test_tolerance,
778                                "target - computed base amounts are greater than tolerance:
779                                error     = {:#?}
780                                tolerance = {:#?}",
781                                target_base_amount - computed_base_amount,
782                                test_tolerance
783                            );
784                        }
785                        Err(_) => continue, // absolute max threw an error
786                    }
787                }
788                Err(_) => continue, // absolute max threw a panic
789            }
790        }
791        Ok(())
792    }
793
794    /// This test differentially fuzzes the `calculate_max_short` function against
795    /// the Solidity analogue `calculateMaxShort`. `calculateMaxShort` doesn't take
796    /// a trader's budget into account, so it only provides a subset of
797    /// `calculate_max_short`'s functionality. With this in mind, we provide
798    /// `calculate_max_short` with a budget of `U256::MAX` to ensure that the two
799    /// functions are equivalent.
800    #[tokio::test]
801    async fn fuzz_sol_calculate_max_short_without_budget() -> Result<()> {
802        // TODO: We should be able to pass these tests with a much lower (if not zero) tolerance.
803        let sol_correctness_tolerance = fixed!(1e17);
804
805        // Fuzz the rust and solidity implementations against each other.
806        let chain = TestChain::new().await?;
807        let mut rng = thread_rng();
808        for _ in 0..*FAST_FUZZ_RUNS {
809            let state = rng.gen::<State>();
810            let checkpoint_exposure = {
811                let value = rng.gen_range(fixed!(0)..=FixedPoint::from(U256::from(U128::MAX)));
812                if rng.gen() {
813                    -I256::try_from(value)?
814                } else {
815                    I256::try_from(value)?
816                }
817            };
818            let max_iterations = 7;
819            // We need to catch panics because of overflows.
820            let rust_max_bond_amount = panic::catch_unwind(|| {
821                state.calculate_absolute_max_short(
822                    state.calculate_spot_price()?,
823                    checkpoint_exposure,
824                    Some(max_iterations),
825                )
826            });
827            // Run the solidity function & compare outputs.
828            match chain
829                .mock_hyperdrive_math()
830                .calculate_max_short(
831                    MaxTradeParams {
832                        share_reserves: state.info.share_reserves,
833                        bond_reserves: state.info.bond_reserves,
834                        longs_outstanding: state.info.longs_outstanding,
835                        long_exposure: state.info.long_exposure,
836                        share_adjustment: state.info.share_adjustment,
837                        time_stretch: state.config.time_stretch,
838                        vault_share_price: state.info.vault_share_price,
839                        initial_vault_share_price: state.config.initial_vault_share_price,
840                        minimum_share_reserves: state.config.minimum_share_reserves,
841                        curve_fee: state.config.fees.curve,
842                        flat_fee: state.config.fees.flat,
843                        governance_lp_fee: state.config.fees.governance_lp,
844                    },
845                    checkpoint_exposure,
846                    max_iterations.into(),
847                )
848                .call()
849                .await
850            {
851                Ok(sol_max_bond_amount) => {
852                    // Make sure the solidity & rust runctions gave the same value.
853                    let rust_max_bonds_unwrapped = rust_max_bond_amount.unwrap().unwrap();
854                    let sol_max_bonds_fp = FixedPoint::from(sol_max_bond_amount);
855                    let error = if sol_max_bonds_fp > rust_max_bonds_unwrapped {
856                        sol_max_bonds_fp - rust_max_bonds_unwrapped
857                    } else {
858                        rust_max_bonds_unwrapped - sol_max_bonds_fp
859                    };
860                    assert!(
861                        error < sol_correctness_tolerance,
862                        "expected abs(solidity_amount={} - rust_amount={})={} < tolerance={}",
863                        sol_max_bonds_fp,
864                        rust_max_bonds_unwrapped,
865                        error,
866                        sol_correctness_tolerance,
867                    );
868                }
869                // Hyperdrive Solidity calculate_max_short threw an error
870                Err(sol_err) => {
871                    assert!(
872                        rust_max_bond_amount.is_err()
873                            || rust_max_bond_amount.as_ref().unwrap().is_err(),
874                        "expected rust_max_short={:#?} to have an error.\nsolidity error={:#?}",
875                        rust_max_bond_amount,
876                        sol_err
877                    );
878                }
879            };
880        }
881        Ok(())
882    }
883
884    #[tokio::test]
885    async fn fuzz_calculate_max_short_budget_consumed() -> Result<()> {
886        // TODO: This should be fixed!(0.0001e18) == 0.01%
887        let budget_tolerance = fixed!(1e18);
888
889        // Spawn a test chain and create two agents -- Alice and Bob. Alice
890        // is funded with a large amount of capital so that she can initialize
891        // the pool. Bob is funded with a small amount of capital so that we
892        // can test `calculate_max_short` when budget is the primary constraint.
893        let mut rng = thread_rng();
894
895        // Initialize the chain and the agents.
896        let chain = TestChain::new().await?;
897        let mut alice = chain.alice().await?;
898        let mut bob = chain.bob().await?;
899        let config = alice.get_config().clone();
900
901        for _ in 0..*FUZZ_RUNS {
902            // Snapshot the chain.
903            let id = chain.snapshot().await?;
904
905            // Fund Alice and Bob.
906            let contribution = rng.gen_range(fixed!(100_000e18)..=fixed!(100_000_000e18));
907            alice.fund(contribution).await?;
908
909            // Alice initializes the pool.
910            let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18));
911            alice.initialize(fixed_rate, contribution, None).await?;
912
913            // Some of the checkpoint passes and variable interest accrues.
914            alice
915                .checkpoint(alice.latest_checkpoint().await?, uint256!(0), None)
916                .await?;
917            let variable_rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18));
918            alice
919                .advance_time(
920                    variable_rate,
921                    FixedPoint::from(config.checkpoint_duration) * fixed!(0.5e18),
922                )
923                .await?;
924
925            // Get the current state of the pool.
926            let state = alice.get_state().await?;
927            let Checkpoint {
928                vault_share_price: open_vault_share_price,
929                weighted_spot_price: _,
930                last_weighted_spot_price_update_time: _,
931            } = alice
932                .get_checkpoint(state.to_checkpoint(alice.now().await?))
933                .await?;
934            let checkpoint_exposure = alice
935                .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?))
936                .await?;
937
938            let global_max_short_bonds = state.calculate_absolute_max_short(
939                state.calculate_spot_price()?,
940                checkpoint_exposure,
941                None,
942            )?;
943
944            // Bob should always be budget constrained when trying to open the short.
945            let global_max_base_required = state
946                .calculate_open_short(global_max_short_bonds, open_vault_share_price.into())?;
947            let budget = rng.gen_range(
948                state.minimum_transaction_amount()..=global_max_base_required - fixed!(1e18),
949            );
950            bob.fund(budget).await?;
951
952            // Bob opens a max short position. We allow for a very small amount
953            // of slippage to account for interest accrual between the time the
954            // calculation is performed and the transaction is submitted.
955            let slippage_tolerance = fixed!(0.0001e18); // 0.01%
956            let max_short_bonds = bob.calculate_max_short(Some(slippage_tolerance)).await?;
957            bob.open_short(max_short_bonds, None, None).await?;
958
959            // Bob used a slippage tolerance of 0.01%, which means
960            // that the max short is always consuming at least 99.99% of
961            // the budget.
962            let max_allowable_balance =
963                budget * (fixed!(1e18) - slippage_tolerance) * budget_tolerance;
964            let remaining_balance = bob.base();
965            assert!(remaining_balance < max_allowable_balance,
966                "expected {}% of budget consumed, or remaining_balance={} < max_allowable_balance={}
967                global_max_short_bonds = {}; max_short_bonds = {}; global_max_base_required={}",
968                format!("{}", fixed!(100e18)*(fixed!(1e18) - budget_tolerance)).trim_end_matches("0"),
969                remaining_balance,
970                max_allowable_balance,
971                global_max_short_bonds,
972                max_short_bonds,
973                global_max_base_required,
974            );
975
976            // Revert to the snapshot and reset the agents' wallets.
977            chain.revert(id).await?;
978            alice.reset(Default::default()).await?;
979            bob.reset(Default::default()).await?;
980        }
981
982        Ok(())
983    }
984
985    #[tokio::test]
986    async fn fuzz_sol_calculate_max_short_without_budget_then_open_short() -> Result<()> {
987        let max_bonds_tolerance = fixed!(1e10);
988        let max_base_tolerance = fixed!(1e10);
989        let reserves_drained_tolerance = fixed!(1e27);
990
991        // Set up a random number generator. We use ChaCha8Rng with a randomly
992        // generated seed, which makes it easy to reproduce test failures given
993        // the seed.
994        let mut rng = {
995            let mut rng = thread_rng();
996            let seed = rng.gen();
997            ChaCha8Rng::seed_from_u64(seed)
998        };
999
1000        // Initialize the test chain.
1001        let chain = TestChain::new().await?;
1002        let mut alice = chain.alice().await?;
1003        let mut bob = chain.bob().await?;
1004        let mut celine = chain.celine().await?;
1005
1006        for _ in 0..*SLOW_FUZZ_RUNS {
1007            // Snapshot the chain.
1008            let id = chain.snapshot().await?;
1009
1010            // Run the preamble.
1011            initialize_pool_with_random_state(&mut rng, &mut alice, &mut bob, &mut celine).await?;
1012
1013            // Get the current state from solidity.
1014            let mut state = alice.get_state().await?;
1015
1016            // Get the current checkpoint exposure.
1017            let checkpoint_exposure = alice
1018                .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?))
1019                .await?;
1020
1021            // Get the global max short from Solidity.
1022            let max_iterations = 7;
1023            match chain
1024                .mock_hyperdrive_math()
1025                .calculate_max_short(
1026                    MaxTradeParams {
1027                        share_reserves: state.info.share_reserves,
1028                        bond_reserves: state.info.bond_reserves,
1029                        longs_outstanding: state.info.longs_outstanding,
1030                        long_exposure: state.info.long_exposure,
1031                        share_adjustment: state.info.share_adjustment,
1032                        time_stretch: state.config.time_stretch,
1033                        vault_share_price: state.info.vault_share_price,
1034                        initial_vault_share_price: state.config.initial_vault_share_price,
1035                        minimum_share_reserves: state.config.minimum_share_reserves,
1036                        curve_fee: state.config.fees.curve,
1037                        flat_fee: state.config.fees.flat,
1038                        governance_lp_fee: state.config.fees.governance_lp,
1039                    },
1040                    checkpoint_exposure,
1041                    max_iterations.into(),
1042                )
1043                .call()
1044                .await
1045            {
1046                Ok(sol_max_bonds) => {
1047                    // Solidity reports everything is good, so we run the Rust fns.
1048                    let rust_max_bonds = panic::catch_unwind(|| {
1049                        state.calculate_absolute_max_short(
1050                            state.calculate_spot_price()?,
1051                            checkpoint_exposure,
1052                            Some(max_iterations),
1053                        )
1054                    });
1055
1056                    // Compare the max bond amounts.
1057                    let rust_max_bonds_unwrapped = rust_max_bonds.unwrap().unwrap();
1058                    let sol_max_bonds_fp = FixedPoint::from(sol_max_bonds);
1059                    let error = if rust_max_bonds_unwrapped > sol_max_bonds_fp {
1060                        rust_max_bonds_unwrapped - sol_max_bonds_fp
1061                    } else {
1062                        sol_max_bonds_fp - rust_max_bonds_unwrapped
1063                    };
1064                    assert!(
1065                        error < max_bonds_tolerance,
1066                        "expected abs(rust_bonds - sol_bonds)={} >= max_bonds_tolerance={}",
1067                        error,
1068                        max_bonds_tolerance
1069                    );
1070
1071                    // The amount Celine has to pay will always be less than the bond amount.
1072                    celine.fund(sol_max_bonds.into()).await?;
1073                    match celine
1074                        .hyperdrive()
1075                        .open_short(
1076                            sol_max_bonds.into(),
1077                            FixedPoint::from(U256::MAX).into(),
1078                            fixed!(0).into(),
1079                            Options {
1080                                destination: celine.address(),
1081                                as_base: true,
1082                                extra_data: [].into(),
1083                            },
1084                        )
1085                        .call()
1086                        .await
1087                    {
1088                        Ok((_, sol_max_base)) => {
1089                            // Calling any Solidity Hyperdrive transaction causes the
1090                            // mock yield source to accrue some interest. We want to use
1091                            // the state before the Solidity OpenShort, but with the
1092                            // vault share price after the block tick.
1093                            // Get the current vault share price & update state.
1094                            let vault_share_price = alice.get_state().await?.vault_share_price();
1095                            state.info.vault_share_price = vault_share_price.into();
1096
1097                            // Get the open vault share price.
1098                            let Checkpoint {
1099                                weighted_spot_price: _,
1100                                last_weighted_spot_price_update_time: _,
1101                                vault_share_price: open_vault_share_price,
1102                            } = alice
1103                                .get_checkpoint(state.to_checkpoint(alice.now().await?))
1104                                .await?;
1105
1106                            // Compare the open short call outputs.
1107                            let rust_max_base = state.calculate_open_short(
1108                                rust_max_bonds_unwrapped,
1109                                open_vault_share_price.into(),
1110                            );
1111
1112                            let rust_max_base_unwrapped = rust_max_base.unwrap();
1113                            let sol_max_base_fp = FixedPoint::from(sol_max_base);
1114                            let error = if rust_max_base_unwrapped > sol_max_base_fp {
1115                                rust_max_base_unwrapped - sol_max_base_fp
1116                            } else {
1117                                sol_max_base_fp - rust_max_base_unwrapped
1118                            };
1119                            assert!(
1120                                error < max_base_tolerance,
1121                                "expected abs(rust_base - sol_base)={} >= max_base_tolerance={}",
1122                                error,
1123                                max_base_tolerance
1124                            );
1125
1126                            // Make sure the pool was drained.
1127                            let pool_shares = state
1128                                .effective_share_reserves()?
1129                                .min(state.share_reserves());
1130                            let min_share_reserves = state.minimum_share_reserves();
1131                            assert!(pool_shares >= min_share_reserves,
1132                                "effective_share_reserves={} should always be greater than the minimum_share_reserves={}.",
1133                                state.effective_share_reserves()?,
1134                                min_share_reserves,
1135                            );
1136                            let reserve_amount_above_minimum = pool_shares - min_share_reserves;
1137                            assert!(reserve_amount_above_minimum < reserves_drained_tolerance,
1138                                "share_reserves={} - minimum_share_reserves={} (diff={}) should be < tolerance={}",
1139                                pool_shares,
1140                                min_share_reserves,
1141                                reserve_amount_above_minimum,
1142                                reserves_drained_tolerance,
1143                            );
1144                        }
1145
1146                        // Solidity calculate_max_short worked, but passing that bond amount to open_short failed.
1147                        Err(_) => assert!(
1148                            false,
1149                            "Solidity calculate_max_short produced an insolvent answer!"
1150                        ),
1151                    }
1152                }
1153
1154                // Solidity calculate_max_short failed; verify that rust calculate_max_short fails.
1155                Err(_) => {
1156                    // Get the current vault share price & update state.
1157                    let vault_share_price = alice.get_state().await?.vault_share_price();
1158                    state.info.vault_share_price = vault_share_price.into();
1159
1160                    // Get the current checkpoint exposure.
1161                    let checkpoint_exposure = alice
1162                        .get_checkpoint_exposure(state.to_checkpoint(alice.now().await?))
1163                        .await?;
1164
1165                    // Solidity reports everything is good, so we run the Rust fns.
1166                    let rust_max_bonds = panic::catch_unwind(|| {
1167                        state.calculate_absolute_max_short(
1168                            state.calculate_spot_price()?,
1169                            checkpoint_exposure,
1170                            Some(max_iterations),
1171                        )
1172                    });
1173
1174                    assert!(rust_max_bonds.is_err() || rust_max_bonds.unwrap().is_err());
1175                }
1176            }
1177
1178            // Revert to the snapshot and reset the agent's wallets.
1179            chain.revert(id).await?;
1180            alice.reset(Default::default()).await?;
1181            bob.reset(Default::default()).await?;
1182            celine.reset(Default::default()).await?;
1183        }
1184
1185        Ok(())
1186    }
1187}