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}