hyperdrive_math/
lib.rs

1mod base;
2mod long;
3mod lp;
4mod short;
5#[cfg(test)]
6mod test_utils;
7mod utils;
8mod yield_space;
9
10use ethers::types::{Address, I256, U256};
11use eyre::{eyre, Result};
12use fixedpointmath::{fixed, fixed_i256, FixedPoint};
13use hyperdrive_wrappers::wrappers::ihyperdrive::{Fees, PoolConfig, PoolInfo};
14use rand::{
15    distributions::{Distribution, Standard},
16    Rng,
17};
18pub use utils::*;
19pub use yield_space::YieldSpace;
20
21#[derive(Clone, Debug)]
22pub struct State {
23    pub config: PoolConfig,
24    pub info: PoolInfo,
25}
26
27impl Distribution<State> for Standard {
28    // TODO: It may be better for this to be a uniform sampler and have a test
29    // sampler that is more restrictive like this.
30    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> State {
31        let one_day_in_seconds = 60 * 60 * 24;
32        let one_hour_in_seconds = 60 * 60;
33        let config = PoolConfig {
34            base_token: Address::zero(),
35            vault_shares_token: Address::zero(),
36            linker_factory: Address::zero(),
37            linker_code_hash: [0; 32],
38            initial_vault_share_price: rng.gen_range(fixed!(0.5e18)..=fixed!(2.5e18)).into(),
39            minimum_share_reserves: rng.gen_range(fixed!(0.1e18)..=fixed!(1e18)).into(),
40            minimum_transaction_amount: rng.gen_range(fixed!(0.1e18)..=fixed!(1e18)).into(),
41            circuit_breaker_delta: rng.gen_range(fixed!(0.01e18)..=fixed!(10e18)).into(),
42            position_duration: rng
43                .gen_range(
44                    FixedPoint::from(91 * one_day_in_seconds)
45                        ..=FixedPoint::from(365 * one_day_in_seconds),
46                )
47                .into(),
48            checkpoint_duration: rng
49                .gen_range(
50                    FixedPoint::from(one_hour_in_seconds)..=FixedPoint::from(one_day_in_seconds),
51                )
52                .into(),
53            time_stretch: rng.gen_range(fixed!(0.005e18)..=fixed!(0.5e18)).into(),
54            governance: Address::zero(),
55            fee_collector: Address::zero(),
56            sweep_collector: Address::zero(),
57            checkpoint_rewarder: Address::zero(),
58            fees: Fees {
59                curve: rng.gen_range(fixed!(0.0001e18)..=fixed!(0.2e18)).into(),
60                flat: rng.gen_range(fixed!(0.0001e18)..=fixed!(0.2e18)).into(),
61                governance_lp: rng.gen_range(fixed!(0.0001e18)..=fixed!(0.2e18)).into(),
62                governance_zombie: rng.gen_range(fixed!(0.0001e18)..=fixed!(0.2e18)).into(),
63            },
64        };
65        let share_reserves = rng.gen_range(fixed!(1_000e18)..=fixed!(100_000_000e18));
66        let share_adjustment = {
67            if rng.gen() {
68                rng.gen_range(fixed_i256!(-100_000e18)..=fixed!(0)).raw()
69            } else {
70                // We generate values that satisfy `z - zeta >= z_min`,
71                // so `z - z_min >= zeta`.
72                // TODO: The upper bound had to be lowered to make tests pass; issue #171
73                I256::try_from(rng.gen_range(
74                    fixed!(0)
75                        ..=(share_reserves
76                            - FixedPoint::from(config.minimum_share_reserves)
77                            - fixed!(10e18)),
78                ))
79                .unwrap()
80            }
81        };
82        let effective_share_reserves =
83            calculate_effective_share_reserves(share_reserves, share_adjustment).unwrap();
84        // We need the spot price to be less than or equal to 1, so we need to
85        // generate the bond reserves so that mu * z <= y.
86        let bond_reserves = rng.gen_range(
87            effective_share_reserves * FixedPoint::from(config.initial_vault_share_price)
88                ..=fixed!(1_000_000_000e18),
89        );
90        // Populate PoolInfo.
91        let info = PoolInfo {
92            share_reserves: share_reserves.into(),
93            zombie_base_proceeds: fixed!(0).into(),
94            zombie_share_reserves: fixed!(0).into(),
95            bond_reserves: bond_reserves.into(),
96            vault_share_price: rng.gen_range(fixed!(0.5e18)..=fixed!(2.5e18)).into(),
97            longs_outstanding: rng.gen_range(fixed!(0)..=fixed!(100_000e18)).into(),
98            shorts_outstanding: rng.gen_range(fixed!(0)..=fixed!(100_000e18)).into(),
99            long_exposure: rng.gen_range(fixed!(0)..=fixed!(100_000e18)).into(),
100            share_adjustment: share_adjustment.into(),
101            // If this range returns greater than position duration, then both rust and solidity will fail
102            // on calls that depend on this value.
103            long_average_maturity_time: rng
104                .gen_range(fixed!(0)..=FixedPoint::from(365 * one_day_in_seconds) * fixed!(1e18))
105                .into(),
106            short_average_maturity_time: rng
107                .gen_range(fixed!(0)..=FixedPoint::from(365 * one_day_in_seconds) * fixed!(1e18))
108                .into(),
109            lp_total_supply: rng
110                .gen_range(fixed!(1_000e18)..=fixed!(100_000_000e18))
111                .into(),
112            // TODO: This should be calculated based on the other values.
113            lp_share_price: rng.gen_range(fixed!(0.01e18)..=fixed!(5e18)).into(),
114            withdrawal_shares_proceeds: rng.gen_range(fixed!(0)..=fixed!(100_000e18)).into(),
115            withdrawal_shares_ready_to_withdraw: rng
116                .gen_range(fixed!(1_000e18)..=fixed!(100_000_000e18))
117                .into(),
118        };
119        State { config, info }
120    }
121}
122
123impl State {
124    /// Creates a new `State` from the given `PoolConfig` and `PoolInfo`.
125    pub fn new(config: PoolConfig, info: PoolInfo) -> Self {
126        Self { config, info }
127    }
128
129    /// Calculates the pool's spot price.
130    pub fn calculate_spot_price(&self) -> Result<FixedPoint<U256>> {
131        YieldSpace::calculate_spot_price(self)
132    }
133
134    /// Calculate the pool's current spot (aka "fixed") rate.
135    pub fn calculate_spot_rate(&self) -> Result<FixedPoint<U256>> {
136        Ok(calculate_rate_given_fixed_price(
137            self.calculate_spot_price()?,
138            self.position_duration(),
139        ))
140    }
141
142    /// Converts a timestamp to the checkpoint timestamp that it corresponds to.
143    pub fn to_checkpoint(&self, time: U256) -> U256 {
144        time - time % self.config.checkpoint_duration
145    }
146
147    /// Calculates the normalized time remaining.
148    fn calculate_normalized_time_remaining(
149        &self,
150        maturity_time: U256,
151        current_time: U256,
152    ) -> FixedPoint<U256> {
153        let latest_checkpoint = self.to_checkpoint(current_time);
154        if maturity_time > latest_checkpoint {
155            // NOTE: Round down to underestimate the time remaining.
156            FixedPoint::from(maturity_time - latest_checkpoint).div_down(self.position_duration())
157        } else {
158            fixed!(0)
159        }
160    }
161
162    /// Calculates the pool reserve levels to achieve a target interest rate.
163    /// This calculation does not take into account Hyperdrive's solvency
164    /// constraints or exposure and shouldn't be used directly.
165    ///
166    /// The price for a given fixed-rate is given by
167    /// `$p = \tfrac{1}{r \cdot t + 1}$`, where `$r$` is the fixed-rate and
168    /// `$t$` is the annualized position duration. The price given pool reserves
169    /// is `$p = \left( \tfrac{\mu \cdot z_e}{y} \right)^{t_s}$`, where `$\mu$`
170    /// is the initial share price and `$t_s$` is the time stretch constant. The
171    /// reserve levels are related using the modified yieldspace formula:
172    /// `$k = \tfrac{\mu}{c}^{-t_s} z_{e}^{1 - t_s} + y^{1 - t_s}$`. Using these
173    /// three equations, we can solve for the pool reserve levels as a function
174    /// of a target rate while ensuring we remain on the same yield curve. For a
175    /// target rate, `$r_t$`, the pool share reserves, `$z_t$`, must be:
176    ///
177    /// ```math
178    /// z_t = \zeta + \frac{1}{\mu} \left(
179    ///   \frac{k}{\frac{c}{\mu} + \left(
180    ///     (r_t \cdot t + 1)^{\frac{1}{t_s}}
181    ///   \right)^{1 - t_{s}}}
182    /// \right)^{\frac{1}{1 - t_{s}}}
183    /// ```
184    ///
185    /// and the pool bond reserves, `$y_t$`, must be:
186    ///
187    /// ```math
188    /// y_t = \left(
189    ///   \frac{k}{ \frac{c}{\mu} +  \left(
190    ///     \left( r_t \cdot t + 1 \right)^{\frac{1}{t_s}}
191    ///   \right)^{1 - t_s}}
192    /// \right)^{1 - t_s} \left( r_t \cdot t + 1 \right)^{\frac{1}{t_s}}
193    /// ```
194    fn reserves_given_rate_ignoring_exposure<F: Into<FixedPoint<U256>>>(
195        &self,
196        target_rate: F,
197    ) -> Result<(FixedPoint<U256>, FixedPoint<U256>)> {
198        let target_rate = target_rate.into();
199
200        // First get the target share reserves
201        let c_over_mu = self
202            .vault_share_price()
203            .div_up(self.initial_vault_share_price());
204        let scaled_rate = (target_rate.mul_up(self.annualized_position_duration()) + fixed!(1e18))
205            .pow(fixed!(1e18) / self.time_stretch())?;
206        let inner = (self.k_down()?
207            / (c_over_mu + scaled_rate.pow(fixed!(1e18) - self.time_stretch())?))
208        .pow(fixed!(1e18) / (fixed!(1e18) - self.time_stretch()))?;
209        let target_effective_share_reserves = inner / self.initial_vault_share_price();
210        let target_share_reserves_i256 =
211            I256::try_from(target_effective_share_reserves)? + self.share_adjustment();
212
213        let target_share_reserves = if target_share_reserves_i256 > I256::from(0) {
214            FixedPoint::try_from(target_share_reserves_i256)?
215        } else {
216            return Err(eyre!("Target rate would result in share reserves <= 0."));
217        };
218
219        // Then get the target bond reserves.
220        let target_bond_reserves = inner * scaled_rate;
221
222        Ok((target_share_reserves, target_bond_reserves))
223    }
224
225    fn position_duration(&self) -> FixedPoint<U256> {
226        self.config.position_duration.into()
227    }
228
229    fn annualized_position_duration(&self) -> FixedPoint<U256> {
230        self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365))
231    }
232
233    fn checkpoint_duration(&self) -> FixedPoint<U256> {
234        self.config.checkpoint_duration.into()
235    }
236
237    fn time_stretch(&self) -> FixedPoint<U256> {
238        self.config.time_stretch.into()
239    }
240
241    fn initial_vault_share_price(&self) -> FixedPoint<U256> {
242        self.config.initial_vault_share_price.into()
243    }
244
245    fn minimum_share_reserves(&self) -> FixedPoint<U256> {
246        self.config.minimum_share_reserves.into()
247    }
248
249    fn minimum_transaction_amount(&self) -> FixedPoint<U256> {
250        self.config.minimum_transaction_amount.into()
251    }
252
253    fn curve_fee(&self) -> FixedPoint<U256> {
254        self.config.fees.curve.into()
255    }
256
257    fn flat_fee(&self) -> FixedPoint<U256> {
258        self.config.fees.flat.into()
259    }
260
261    fn governance_lp_fee(&self) -> FixedPoint<U256> {
262        self.config.fees.governance_lp.into()
263    }
264
265    pub fn vault_share_price(&self) -> FixedPoint<U256> {
266        self.info.vault_share_price.into()
267    }
268
269    fn share_reserves(&self) -> FixedPoint<U256> {
270        self.info.share_reserves.into()
271    }
272
273    fn effective_share_reserves(&self) -> Result<FixedPoint<U256>> {
274        calculate_effective_share_reserves(self.share_reserves(), self.share_adjustment())
275    }
276
277    fn bond_reserves(&self) -> FixedPoint<U256> {
278        self.info.bond_reserves.into()
279    }
280
281    fn longs_outstanding(&self) -> FixedPoint<U256> {
282        self.info.longs_outstanding.into()
283    }
284
285    fn long_average_maturity_time(&self) -> FixedPoint<U256> {
286        self.info.long_average_maturity_time.into()
287    }
288
289    fn shorts_outstanding(&self) -> FixedPoint<U256> {
290        self.info.shorts_outstanding.into()
291    }
292
293    fn short_average_maturity_time(&self) -> FixedPoint<U256> {
294        self.info.short_average_maturity_time.into()
295    }
296
297    fn long_exposure(&self) -> FixedPoint<U256> {
298        self.info.long_exposure.into()
299    }
300
301    fn share_adjustment(&self) -> I256 {
302        self.info.share_adjustment
303    }
304
305    fn lp_total_supply(&self) -> FixedPoint<U256> {
306        self.info.lp_total_supply.into()
307    }
308
309    fn withdrawal_shares_proceeds(&self) -> FixedPoint<U256> {
310        self.info.withdrawal_shares_proceeds.into()
311    }
312
313    fn withdrawal_shares_ready_to_withdraw(&self) -> FixedPoint<U256> {
314        self.info.withdrawal_shares_ready_to_withdraw.into()
315    }
316}
317
318impl YieldSpace for State {
319    fn z(&self) -> FixedPoint<U256> {
320        self.share_reserves()
321    }
322
323    fn zeta(&self) -> I256 {
324        self.share_adjustment()
325    }
326
327    fn y(&self) -> FixedPoint<U256> {
328        self.bond_reserves()
329    }
330
331    fn mu(&self) -> FixedPoint<U256> {
332        self.initial_vault_share_price()
333    }
334
335    fn c(&self) -> FixedPoint<U256> {
336        self.vault_share_price()
337    }
338
339    fn t(&self) -> FixedPoint<U256> {
340        self.time_stretch()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use fixedpointmath::{fixed, uint256};
347    use hyperdrive_test_utils::constants::FAST_FUZZ_RUNS;
348    use rand::thread_rng;
349
350    use super::*;
351
352    #[tokio::test]
353    async fn fuzz_reserves_given_rate_ignoring_exposure() -> Result<()> {
354        let test_tolerance = fixed!(1e15);
355        let mut rng = thread_rng();
356        let mut counter = 0;
357        for _ in 0..*FAST_FUZZ_RUNS {
358            // We want a state with net zero exposure and zero fees.
359            let mut state = rng.gen::<State>();
360            // Zero exposure
361            state.info.longs_outstanding = uint256!(0);
362            state.info.long_average_maturity_time = uint256!(0);
363            state.info.long_exposure = uint256!(0);
364            state.info.shorts_outstanding = uint256!(0);
365            state.info.short_average_maturity_time = uint256!(0);
366            // Make sure we're still solvent
367            if state.calculate_spot_price()? < state.calculate_min_spot_price()?
368                || state.calculate_spot_price()? > fixed!(1e18)
369                || state.calculate_solvency().is_err()
370            {
371                continue;
372            }
373            // Pick a random target rate that is near the current rate.
374            let min_rate = calculate_rate_given_fixed_price(
375                state.calculate_max_spot_price()?,
376                state.position_duration(),
377            );
378            let max_rate = calculate_rate_given_fixed_price(
379                state.calculate_min_spot_price()?,
380                state.position_duration(),
381            );
382            let target_rate = rng.gen_range(min_rate..=max_rate);
383            // Estimate the long that achieves a target rate.
384            // The random target rate could be impossible to achieve and remain
385            // solvent. If so we want to catch that and not fail the test.
386            // TODO: Since we get the min & max price from the state, should this always work?
387            let (target_share_reserves, target_bond_reserves) =
388                match state.reserves_given_rate_ignoring_exposure(target_rate) {
389                    Ok(result) => result,
390                    Err(err) => {
391                        if err
392                            .to_string()
393                            .contains("Target rate would result in share reserves <= 0.")
394                        {
395                            continue;
396                        } else {
397                            return Err(err);
398                        }
399                    }
400                };
401            // Verify that the new levels are solvent.
402            let mut new_state = state.clone();
403            new_state.info.share_reserves = target_share_reserves.into();
404            new_state.info.bond_reserves = target_bond_reserves.into();
405            if new_state.calculate_solvency().is_err()
406                || new_state.calculate_spot_price()? > fixed!(1e18)
407            {
408                continue;
409            }
410            // Fixed rate for the new state should equal the target rate.
411            let realized_rate = new_state.calculate_spot_rate()?;
412            let error = if realized_rate > target_rate {
413                realized_rate - target_rate
414            } else {
415                target_rate - realized_rate
416            };
417            assert!(
418                error <= test_tolerance,
419                "expected error={} <= tolerance={}",
420                error,
421                test_tolerance
422            );
423            counter += 1;
424        }
425        assert!(counter >= 5_000); // at least FAST_FUZZ_RUNS / 2of runs passed
426        Ok(())
427    }
428
429    #[tokio::test]
430    async fn test_calculate_normalized_time_remaining() -> Result<()> {
431        // TODO: fuzz test against calculateTimeRemaining in MockHyperdrive.sol
432        let mut rng = thread_rng();
433        let mut state = rng.gen::<State>();
434
435        // Set a snapshot for the values used for calculating normalized time
436        // remaining
437        state.config.position_duration = fixed!(28209717).into();
438        state.config.checkpoint_duration = fixed!(43394).into();
439        let expected_time_remaining = fixed!(3544877816392);
440
441        let maturity_time = U256::from(100);
442        let current_time = U256::from(90);
443        let time_remaining = state.calculate_normalized_time_remaining(maturity_time, current_time);
444
445        assert_eq!(expected_time_remaining, time_remaining);
446        Ok(())
447    }
448}