hyperdrive_math/lp/
add.rs

1use ethers::types::{I256, U256};
2use eyre::{eyre, Result};
3use fixedpointmath::FixedPoint;
4
5use crate::State;
6
7impl State {
8    /// Calculates the LP shares for a given contribution when adding liquidity.
9    pub fn calculate_add_liquidity(
10        &self,
11        current_block_timestamp: U256,
12        contribution: FixedPoint<U256>,
13        min_lp_share_price: FixedPoint<U256>,
14        min_apr: FixedPoint<U256>,
15        max_apr: FixedPoint<U256>,
16        as_base: bool,
17    ) -> Result<FixedPoint<U256>> {
18        // Enforce the slippage guard.
19        let apr = self.calculate_spot_rate()?;
20        if apr < min_apr || apr > max_apr {
21            return Err(eyre!("InvalidApr: Apr is outside the slippage guard."));
22        }
23
24        // Get lp_total_supply for the lp_shares calculation.
25        let lp_total_supply = self.lp_total_supply();
26
27        // Get the starting_present_value.
28        let starting_present_value = self.calculate_present_value(current_block_timestamp)?;
29
30        // Get the ending_present_value.
31        let new_state = self.calculate_pool_state_after_add_liquidity(contribution, as_base)?;
32        let ending_present_value = new_state.calculate_present_value(current_block_timestamp)?;
33
34        // Ensure the present value didn't decrease after adding liquidity.
35        if ending_present_value < starting_present_value {
36            return Err(eyre!("DecreasedPresentValueWhenAddingLiquidity: Present value decreased after adding liquidity."));
37        }
38
39        // Calculate the lp_shares.
40        let lp_shares = (ending_present_value - starting_present_value)
41            .mul_div_down(lp_total_supply, starting_present_value);
42
43        // Ensure that enough lp_shares are minted so that they can be redeemed.
44        if lp_shares < self.minimum_transaction_amount() {
45            return Err(eyre!(
46                "MinimumTransactionAmount: Not enough lp shares minted."
47            ));
48        }
49
50        // Enforce the minimum LP share price slippage guard.
51        if contribution.div_down(lp_shares) < min_lp_share_price {
52            return Err(eyre!("OutputLimit: Not enough lp shares minted."));
53        }
54
55        Ok(lp_shares)
56    }
57
58    pub fn calculate_pool_state_after_add_liquidity(
59        &self,
60        contribution: FixedPoint<U256>,
61        as_base: bool,
62    ) -> Result<State> {
63        // Ensure that the contribution is greater than or equal to the minimum
64        // transaction amount.
65        if contribution < self.minimum_transaction_amount() {
66            return Err(eyre!(
67                "MinimumTransactionAmount: Contribution is smaller than the minimum transaction."
68            ));
69        }
70
71        let share_contribution = {
72            if as_base {
73                I256::try_from(contribution.div_down(self.vault_share_price()))?
74            } else {
75                I256::try_from(contribution)?
76            }
77        };
78        Ok(self.get_state_after_liquidity_update(share_contribution)?)
79    }
80
81    pub fn calculate_pool_deltas_after_add_liquidity(
82        &self,
83        contribution: FixedPoint<U256>,
84        as_base: bool,
85    ) -> Result<(FixedPoint<U256>, I256, FixedPoint<U256>)> {
86        let share_contribution = match as_base {
87            true => contribution / self.vault_share_price(),
88            false => contribution,
89        };
90        let (share_reserves, share_adjustment, bond_reserves) = self.calculate_update_liquidity(
91            self.share_reserves(),
92            self.share_adjustment(),
93            self.bond_reserves(),
94            self.minimum_share_reserves(),
95            I256::from(0),
96        )?;
97        let (new_share_reserves, new_share_adjustment, new_bond_reserves) = self
98            .calculate_update_liquidity(
99                self.share_reserves(),
100                self.share_adjustment(),
101                self.bond_reserves(),
102                self.minimum_share_reserves(),
103                I256::try_from(share_contribution)?,
104            )?;
105        Ok((
106            new_share_reserves - share_reserves,
107            new_share_adjustment - share_adjustment,
108            new_bond_reserves - bond_reserves,
109        ))
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use std::panic;
116
117    use fixedpointmath::{fixed, int256, uint256};
118    use hyperdrive_test_utils::{
119        chain::TestChain,
120        constants::{FAST_FUZZ_RUNS, FUZZ_RUNS},
121    };
122    use rand::{thread_rng, Rng};
123
124    use super::*;
125    use crate::test_utils::agent::HyperdriveMathAgent;
126
127    #[tokio::test]
128    async fn fuzz_calculate_add_liquidity_unhappy_with_random_state() -> Result<()> {
129        // Get the State from solidity before adding liquidity.
130        let mut rng = thread_rng();
131
132        for _ in 0..*FAST_FUZZ_RUNS {
133            let state = rng.gen::<State>();
134            let contribution = rng.gen_range(fixed!(0)..=state.bond_reserves());
135            let current_block_timestamp = U256::from(rng.gen_range(0..=60 * 60 * 24 * 365));
136            let min_lp_share_price = rng.gen_range(fixed!(0)..=fixed!(10e18));
137            let min_apr = rng.gen_range(fixed!(0)..fixed!(1e18));
138            let max_apr = rng.gen_range(fixed!(5e17)..fixed!(1e18));
139
140            // Calculate lp_shares from the rust function.
141            // Testing mostly unhappy paths here since random state will mostly fail.
142            match panic::catch_unwind(|| {
143                state.calculate_add_liquidity(
144                    current_block_timestamp,
145                    contribution,
146                    min_lp_share_price,
147                    min_apr,
148                    max_apr,
149                    true,
150                )
151            }) {
152                Ok(lp_shares) => match lp_shares {
153                    Ok(lp_shares) => assert!(lp_shares >= min_lp_share_price),
154                    Err(err) => {
155                        let message = err.to_string();
156
157                        if message == "MinimumTransactionAmount: Contribution is smaller than the minimum transaction." {
158                            assert!(contribution < state.minimum_transaction_amount());
159                        }
160
161                        else if message == "InvalidApr: Apr is outside the slippage guard." {
162                            let apr = state.calculate_spot_rate()?;
163                            assert!(apr < min_apr || apr > max_apr);
164                        }
165
166                        else if message == "DecreasedPresentValueWhenAddingLiquidity: Present value decreased after adding liquidity." {
167                            let share_contribution =
168                                I256::try_from(contribution / state.vault_share_price()).unwrap();
169                            let new_state = state.get_state_after_liquidity_update(share_contribution)?;
170                            let starting_present_value = state.calculate_present_value(current_block_timestamp)?;
171                            let ending_present_value = new_state.calculate_present_value(current_block_timestamp)?;
172                            assert!(ending_present_value < starting_present_value);
173                        }
174
175                        else if message == "MinimumTransactionAmount: Not enough lp shares minted." {
176                            let share_contribution =
177                                I256::try_from(contribution / state.vault_share_price()).unwrap();
178                            let new_state = state.get_state_after_liquidity_update(share_contribution)?;
179                            let starting_present_value = state.calculate_present_value(current_block_timestamp)?;
180                            let ending_present_value = new_state.calculate_present_value(current_block_timestamp)?;
181                            let lp_shares = (ending_present_value - starting_present_value)
182                                .mul_div_down(state.lp_total_supply(), starting_present_value);
183                            assert!(lp_shares < state.minimum_transaction_amount());
184                        }
185
186                        else if message == "OutputLimit: Not enough lp shares minted." {
187                            let share_contribution =
188                                I256::try_from(contribution / state.vault_share_price()).unwrap();
189                            let new_state = state.get_state_after_liquidity_update(share_contribution)?;
190                            let starting_present_value = state.calculate_present_value(current_block_timestamp)?;
191                            let ending_present_value = new_state.calculate_present_value(current_block_timestamp)?;
192                            let lp_shares = (ending_present_value - starting_present_value)
193                                .mul_div_down(state.lp_total_supply(), starting_present_value);
194                            assert!(contribution.div_down(lp_shares) < min_lp_share_price);
195                        }
196                    }
197                },
198                Err(_) => continue, // FixedPoint<U256> underflow or overflow.
199            }
200        }
201
202        Ok(())
203    }
204    #[tokio::test]
205    async fn fuzz_calculate_add_liquidity() -> Result<()> {
206        // Spawn a test chain and create two agents -- Alice and Bob.
207        let mut rng = thread_rng();
208        let chain = TestChain::new().await?;
209        let mut alice = chain.alice().await?;
210        let mut bob = chain.bob().await?;
211        let config = bob.get_config().clone();
212
213        // Test happy paths.
214        for _ in 0..*FUZZ_RUNS {
215            // Snapshot the chain.
216            let id = chain.snapshot().await?;
217
218            // Fund Alice and Bob.
219            let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18));
220            let contribution = rng.gen_range(fixed!(10_000e18)..=fixed!(500_000_000e18));
221            let budget = rng.gen_range(fixed!(10e18)..=fixed!(500_000_000e18));
222            alice.fund(contribution).await?;
223            bob.fund(budget).await?;
224
225            // Alice initializes the pool.
226            alice.initialize(fixed_rate, contribution, None).await?;
227
228            // Some of the checkpoint passes and variable interest accrues.
229            alice
230                .checkpoint(alice.latest_checkpoint().await?, uint256!(0), None)
231                .await?;
232            let rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18));
233            alice
234                .advance_time(
235                    rate,
236                    FixedPoint::from(config.checkpoint_duration) * fixed!(0.5e18),
237                )
238                .await?;
239
240            // Get the State from solidity before adding liquidity.
241            let hd_state = bob.get_state().await?;
242            let state = State {
243                config: hd_state.config.clone(),
244                info: hd_state.info.clone(),
245            };
246
247            // Bob adds liquidity
248            bob.add_liquidity(budget, None).await?;
249            let lp_shares_mock = bob.lp_shares();
250
251            // Calculate lp_shares from the rust function.
252            let lp_shares = state
253                .calculate_add_liquidity(
254                    bob.now().await?,
255                    budget,
256                    fixed!(0),
257                    fixed!(0),
258                    FixedPoint::from(U256::MAX),
259                    true,
260                )
261                .unwrap();
262
263            // Rust can't account for slippage.
264            assert!(lp_shares >= lp_shares_mock, "Should over estimate.");
265            // Answer should still be mostly the same.
266            assert!(
267                fixed!(1e18) - lp_shares_mock / lp_shares < fixed!(1e11),
268                "Difference should be less than 0.0000001."
269            );
270
271            // Revert to the snapshot and reset the agent's wallets.
272            chain.revert(id).await?;
273            alice.reset(Default::default()).await?;
274            bob.reset(Default::default()).await?;
275        }
276
277        Ok(())
278    }
279
280    #[tokio::test]
281    async fn fuzz_calculate_pool_state_after_add_liquidity() -> Result<()> {
282        // Spawn a test chain and create two agents -- Alice and Bob.
283        let mut rng = thread_rng();
284        let chain = TestChain::new().await?;
285        let mut alice = chain.alice().await?;
286        let mut bob = chain.bob().await?;
287        let config = bob.get_config().clone();
288
289        // Test happy paths.
290        for _ in 0..*FUZZ_RUNS {
291            // Snapshot the chain.
292            let id = chain.snapshot().await?;
293
294            // Fund Alice and Bob.
295            let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18));
296            let contribution = rng.gen_range(fixed!(10_000e18)..=fixed!(500_000_000e18));
297            let budget = rng.gen_range(fixed!(10e18)..=fixed!(500_000_000e18));
298            alice.fund(contribution).await?;
299            bob.fund(budget).await?;
300
301            // Alice initializes the pool.
302            alice.initialize(fixed_rate, contribution, None).await?;
303
304            // Some of the checkpoint passes and variable interest accrues.
305            alice
306                .checkpoint(alice.latest_checkpoint().await?, uint256!(0), None)
307                .await?;
308            let rate = fixed!(0);
309            alice
310                .advance_time(
311                    rate,
312                    FixedPoint::from(config.checkpoint_duration).mul_down(fixed!(0.5e18)),
313                )
314                .await?;
315
316            // Get the State from solidity before adding liquidity.
317            let state = State {
318                config: bob.get_state().await?.config.clone(),
319                info: bob.get_state().await?.info.clone(),
320            };
321
322            // Bob adds liquidity
323            bob.add_liquidity(budget, None).await?;
324
325            // Get the State from solidity after adding liquidity.
326            let expected_state = State {
327                config: bob.get_state().await?.config.clone(),
328                info: bob.get_state().await?.info.clone(),
329            };
330
331            // Calculate lp_shares from the rust function.
332            let actual_state = state
333                .calculate_pool_state_after_add_liquidity(budget, true)
334                .unwrap();
335
336            // Ensure the states are equal within a tolerance.
337            let share_reserves_equal = expected_state.share_reserves()
338                <= actual_state.share_reserves() + fixed!(1e9)
339                && expected_state.share_reserves() >= actual_state.share_reserves() - fixed!(1e9);
340            assert!(share_reserves_equal, "Share reserves should be equal.");
341
342            let bond_reserves_equal = expected_state.bond_reserves()
343                <= actual_state.bond_reserves() + fixed!(1e10)
344                && expected_state.bond_reserves() >= actual_state.bond_reserves() - fixed!(1e10);
345            assert!(bond_reserves_equal, "Bond reserves should be equal.");
346
347            let share_adjustment_equal = expected_state.share_adjustment()
348                <= actual_state.share_adjustment() + int256!(1e10)
349                && expected_state.share_adjustment()
350                    >= actual_state.share_adjustment() - int256!(1e10);
351            assert!(share_adjustment_equal, "Share adjustment should be equal.");
352
353            // Revert to the snapshot and reset the agent's wallets.
354            chain.revert(id).await?;
355            alice.reset(Default::default()).await?;
356            bob.reset(Default::default()).await?;
357        }
358
359        Ok(())
360    }
361}