hyperdrive_math/lp/
remove.rs

1use ethers::types::{I256, U256};
2use eyre::{eyre, Result};
3use fixedpointmath::{fixed, FixedPoint};
4
5use super::math::SHARE_PROCEEDS_MAX_ITERATIONS;
6use crate::State;
7
8impl State {
9    /// Allows an LP to burn shares and withdraw from the pool.
10    pub fn calculate_remove_liquidity(
11        &self,
12        current_block_timestamp: U256,
13        active_lp_total_supply: FixedPoint<U256>,
14        withdrawal_shares_total_supply: FixedPoint<U256>,
15        lp_shares: FixedPoint<U256>,
16        total_vault_shares: FixedPoint<U256>,
17        total_vault_assets: FixedPoint<U256>,
18        min_output_per_share: FixedPoint<U256>,
19        minimum_transaction_amount: FixedPoint<U256>,
20        as_base: bool,
21    ) -> Result<(FixedPoint<U256>, FixedPoint<U256>, State)> {
22        // Ensure that the amount of LP shares to remove is greater than or
23        // equal to the minimum transaction amount.
24        if lp_shares < minimum_transaction_amount {
25            return Err(eyre!("Minimum transaction amount not met"));
26        }
27
28        // Burn the LP's shares.
29        let mut state = self.clone();
30        state.info.lp_total_supply -= lp_shares.into();
31        let active_lp_total_supply = active_lp_total_supply - lp_shares;
32
33        // Mint an equivalent amount of withdrawal shares.
34        let withdrawal_shares_total_supply = withdrawal_shares_total_supply + lp_shares;
35
36        // Redeem as many of the withdrawal shares as possible.
37        let (proceeds, withdrawal_shares_redeemed, updated_state) = state
38            .redeem_withddrawal_shares(
39                current_block_timestamp,
40                active_lp_total_supply,
41                withdrawal_shares_total_supply,
42                lp_shares,
43                total_vault_shares,
44                total_vault_assets,
45                min_output_per_share,
46                as_base,
47            )?;
48        let withdrawal_shares = lp_shares - withdrawal_shares_redeemed;
49
50        Ok((proceeds, withdrawal_shares, updated_state))
51    }
52
53    /// Redeems withdrawal shares by giving the LP a pro-rata amount of the
54    /// withdrawal pool's proceeds. This function redeems the maximum amount of
55    /// the specified withdrawal shares given the amount of withdrawal shares
56    /// ready to withdraw.
57    pub fn redeem_withddrawal_shares(
58        &self,
59        current_block_timestamp: U256,
60        active_lp_total_supply: FixedPoint<U256>,
61        withdrawal_shares_total_supply: FixedPoint<U256>,
62        withdrawal_shares: FixedPoint<U256>,
63        total_supply: FixedPoint<U256>,
64        total_assets: FixedPoint<U256>,
65        min_output_per_share: FixedPoint<U256>,
66        as_base: bool,
67    ) -> Result<(FixedPoint<U256>, FixedPoint<U256>, State)> {
68        // Distribute the excess idle to the withdrawal pool. If the distribute
69        // excess idle calculation fails, we proceed with the calculation since
70        // LPs should be able to redeem their withdrawal shares for existing
71        // withdrawal proceeds regardless of whether or not idle could be
72        // distributed.
73        let (_withdrawal_shares_redeemed, _share_proceeds, updated_state, _success) = self
74            .distribute_excess_idle(
75                current_block_timestamp,
76                active_lp_total_supply,
77                withdrawal_shares_total_supply,
78                SHARE_PROCEEDS_MAX_ITERATIONS,
79            )?;
80
81        let ready_to_withdraw = updated_state.withdrawal_shares_ready_to_withdraw();
82        let withdrawal_share_proceeds = updated_state.withdrawal_shares_proceeds();
83
84        // Clamp the shares to the total amount of shares ready for withdrawal
85        // to avoid unnecessary reverts. We exit early if the user has no shares
86        // available to redeem.
87        let mut withdrawal_shares_redeemed = withdrawal_shares;
88        if withdrawal_shares_redeemed > ready_to_withdraw {
89            withdrawal_shares_redeemed = ready_to_withdraw;
90        }
91        if withdrawal_shares_redeemed == fixed!(0) {
92            return Ok((fixed!(0), fixed!(0), self.clone()));
93        }
94
95        // NOTE: Round down to underestimate the share proceeds.
96        //
97        // The LP gets the pro-rata amount of the collected proceeds.
98        let vault_share_price = updated_state.vault_share_price();
99        let share_proceeds =
100            withdrawal_shares_redeemed.mul_div_down(withdrawal_share_proceeds, ready_to_withdraw);
101
102        // Apply the update to the withdrawal pool.
103        let mut updated_state = updated_state.clone();
104        updated_state.info.withdrawal_shares_ready_to_withdraw -= withdrawal_shares_redeemed.into();
105        updated_state.info.withdrawal_shares_proceeds -= share_proceeds.into();
106
107        // Withdraw the share proceeds to the user
108        let proceeds = updated_state.withdraw(
109            share_proceeds,
110            vault_share_price,
111            total_supply,
112            total_assets,
113            as_base,
114        )?;
115
116        // NOTE: Round up to make the check more conservative.
117        //
118        // Ensure proceeds meet minimum output per share
119        if proceeds < min_output_per_share.mul_up(withdrawal_shares_redeemed) {
120            return Err(eyre!("Output limit not met"));
121        }
122
123        Ok((proceeds, withdrawal_shares_redeemed, updated_state))
124    }
125
126    /// Distribute as much of the excess idle as possible to the withdrawal
127    /// pool while holding the LP share price constant.
128    fn distribute_excess_idle(
129        &self,
130        current_block_timestamp: U256,
131        active_lp_total_supply: FixedPoint<U256>,
132        withdrawal_shares_total_supply: FixedPoint<U256>,
133        max_iterations: u64,
134    ) -> Result<(FixedPoint<U256>, FixedPoint<U256>, State, bool)> {
135        let withdrawal_shares_total_supply =
136            withdrawal_shares_total_supply - self.withdrawal_shares_ready_to_withdraw();
137
138        // If there are no withdrawal shares, then there is nothing to
139        // distribute.
140        if withdrawal_shares_total_supply == fixed!(0) {
141            return Ok((fixed!(0), fixed!(0), self.clone(), true));
142        }
143
144        // If there is no excess idle, then there is nothing to distribute.
145        let idle = self.calculate_idle_share_reserves();
146        if idle == fixed!(0) {
147            return Ok((fixed!(0), fixed!(0), self.clone(), true));
148        }
149
150        // Calculate the amount of withdrawal shares that should be redeemed
151        // and their share proceeds.
152        let (withdrawal_shares_redeemed, share_proceeds) = self.calculate_distribute_excess_idle(
153            current_block_timestamp,
154            active_lp_total_supply,
155            withdrawal_shares_total_supply,
156            max_iterations,
157        )?;
158
159        // Remove the withdrawal pool proceeds from the reserves.
160        match self.calculate_update_liquidity(
161            self.share_reserves(),
162            self.share_adjustment(),
163            self.bond_reserves(),
164            self.minimum_share_reserves(),
165            -I256::try_from(share_proceeds)?,
166        ) {
167            Ok(_) => {}
168            Err(_) => return Ok((fixed!(0), fixed!(0), self.clone(), false)),
169        };
170
171        // Update the withdrawal pool's state.
172        let mut updated_state =
173            self.get_state_after_liquidity_update(-I256::try_from(share_proceeds)?)?;
174        updated_state.info.withdrawal_shares_ready_to_withdraw += withdrawal_shares_redeemed.into();
175        updated_state.info.withdrawal_shares_proceeds += share_proceeds.into();
176
177        return Ok((
178            withdrawal_shares_redeemed,
179            share_proceeds,
180            updated_state,
181            true,
182        ));
183    }
184
185    fn withdraw(
186        &self,
187        shares: FixedPoint<U256>,
188        vault_share_price: FixedPoint<U256>,
189        total_shares: FixedPoint<U256>,
190        total_assets: FixedPoint<U256>,
191        as_base: bool,
192    ) -> Result<FixedPoint<U256>> {
193        // Withdraw logic here, returning the amount withdrawn
194        let base_amount = shares.mul_down(vault_share_price);
195        let shares = self.convert_to_shares(base_amount, total_shares, total_assets)?;
196
197        if as_base {
198            let amount_withdrawn = self.convert_to_assets(shares, total_shares, total_assets)?;
199            return Ok(amount_withdrawn);
200        }
201        Ok(shares)
202    }
203
204    fn convert_to_shares(
205        &self,
206        base_amount: FixedPoint<U256>,
207        total_supply: FixedPoint<U256>,
208        total_assets: FixedPoint<U256>,
209    ) -> Result<FixedPoint<U256>> {
210        Ok(base_amount.mul_div_down(total_supply, total_assets))
211    }
212
213    fn convert_to_assets(
214        &self,
215        share_amount: FixedPoint<U256>,
216        total_supply: FixedPoint<U256>,
217        total_assets: FixedPoint<U256>,
218    ) -> Result<FixedPoint<U256>> {
219        Ok(share_amount.mul_div_down(total_assets, total_supply))
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use std::cmp::min;
226
227    use fixedpointmath::uint256;
228    use hyperdrive_test_utils::{chain::TestChain, constants::FUZZ_RUNS};
229    use hyperdrive_wrappers::wrappers::ihyperdrive::Options;
230    use rand::{thread_rng, Rng};
231
232    use super::*;
233    use crate::test_utils::agent::HyperdriveMathAgent;
234
235    #[tokio::test]
236    async fn fuzz_test_calculate_remove_liquidity() -> Result<()> {
237        // Spawn a test chain and create two agents -- Alice and Bob.
238        let mut rng = thread_rng();
239        let chain = TestChain::new().await?;
240        let mut alice = chain.alice().await?;
241        let mut bob = chain.bob().await?;
242        let config = bob.get_config().clone();
243
244        for _ in 0..*FUZZ_RUNS {
245            // Snapshot the chain.
246            let id = chain.snapshot().await?;
247
248            // Fund Alice and Bob.
249            let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18));
250            let contribution = rng.gen_range(fixed!(10_000e18)..=fixed!(500_000_000e18));
251            let budget = rng.gen_range(fixed!(10e18)..=fixed!(500_000_000e18));
252            alice.fund(contribution).await?;
253            bob.fund(budget).await?;
254
255            // Alice initializes the pool.
256            alice.initialize(fixed_rate, contribution, None).await?;
257
258            // Some of the checkpoint passes and variable interest accrues.
259            alice
260                .checkpoint(alice.latest_checkpoint().await?, uint256!(0), None)
261                .await?;
262            let rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18));
263            alice
264                .advance_time(
265                    rate,
266                    FixedPoint::from(config.checkpoint_duration) * fixed!(0.5e18),
267                )
268                .await?;
269
270            // Bob adds liquidity
271            bob.add_liquidity(budget, None).await?;
272
273            // Get total supply and total assets for the next block to make
274            // sure rust has the same values as the solidity does.
275            let timestamp = alice.now().await?;
276            let total_supply: FixedPoint<U256> = bob.vault().total_supply().call().await?.into();
277            let total_assets: FixedPoint<U256> = bob
278                .vault()
279                .total_assets_with_timestamp(timestamp + uint256!(1))
280                .call()
281                .await?
282                .into();
283
284            // Get the State from solidity before removing liquidity.
285            let hd_state = bob.get_state().await?;
286            let mut state = State {
287                config: hd_state.config.clone(),
288                info: hd_state.info.clone(),
289            };
290
291            // active_lp_total_supply and withdrawal_shares_total_supply are
292            // just the token supplies.  Get them from the contract.
293            let lp_token_asset_id = U256::zero();
294            let active_lp_total_supply: FixedPoint<U256> = bob
295                .hyperdrive()
296                .total_supply(lp_token_asset_id)
297                .await?
298                .into();
299            let withdrawal_share_asset_id = U256::from(3) << 248;
300            let withdrawal_shares_total_supply: FixedPoint<U256> = bob
301                .hyperdrive()
302                .total_supply(withdrawal_share_asset_id)
303                .await?
304                .into();
305
306            // Get the amount to remove, hit the budget 1% of the time to test
307            // that case but don't remove more than is possible.
308            let remove_budget = min(
309                rng.gen_range(fixed!(0)..=fixed!(1.01e18) * bob.wallet.lp_shares),
310                bob.wallet.lp_shares,
311            );
312            let remove_budget = min(
313                active_lp_total_supply - fixed!(2e18) * state.minimum_share_reserves(),
314                remove_budget,
315            );
316
317            // Bob removes liquidity.
318            let as_base = true;
319            let options = Options {
320                destination: bob.client().address(),
321                as_base,
322                extra_data: [].into(),
323            };
324            let tx_result = bob
325                .remove_liquidity(remove_budget, Some(options), None)
326                .await;
327            let sol_final_state = bob.get_state().await?;
328
329            // Get values for the block at which solidity code ran.
330            let current_block_timestamp = bob.now().await?;
331            let vault_share_price = bob.get_state().await?.info.vault_share_price;
332            state.info.vault_share_price = vault_share_price;
333
334            // Calculate shares and withdrawal shares from the rust function.
335            let result = std::panic::catch_unwind(|| {
336                state
337                    .calculate_remove_liquidity(
338                        current_block_timestamp,
339                        active_lp_total_supply,
340                        withdrawal_shares_total_supply,
341                        remove_budget,
342                        total_supply,
343                        total_assets,
344                        fixed!(0),
345                        fixed!(1),
346                        as_base,
347                    )
348                    .unwrap()
349            });
350
351            match result {
352                Ok((rust_amount, rust_withdrawal_shares, rust_final_state)) => {
353                    let (sol_amount, sol_withdrawal_shares) = tx_result?;
354                    // Assert amounts redeemed match between rust and solidity.
355                    assert!(rust_amount == sol_amount.into());
356
357                    // Assert withdrawal shares results match between rust and
358                    // solidity
359                    assert!(rust_withdrawal_shares == sol_withdrawal_shares.into());
360
361                    // Assert states are the same
362                    assert!(sol_final_state.bond_reserves() == rust_final_state.bond_reserves());
363                    assert!(sol_final_state.share_reserves() == rust_final_state.share_reserves());
364                    assert!(
365                        sol_final_state.lp_total_supply() == rust_final_state.lp_total_supply()
366                    );
367                    assert!(
368                        sol_final_state.share_adjustment() == rust_final_state.share_adjustment()
369                    );
370                    assert!(
371                        sol_final_state.withdrawal_shares_ready_to_withdraw()
372                            == rust_final_state.withdrawal_shares_ready_to_withdraw()
373                    );
374                    assert!(
375                        sol_final_state.withdrawal_shares_proceeds()
376                            == rust_final_state.withdrawal_shares_proceeds()
377                    );
378                }
379                Err(err) => {
380                    println!("err {:#?}", err);
381                    println!("tx_result {:#?}", tx_result);
382                    assert!(tx_result.is_err());
383                }
384            }
385
386            // Revert to the snapshot and reset the agent's wallets.
387            chain.revert(id).await?;
388            alice.reset(Default::default()).await?;
389            bob.reset(Default::default()).await?;
390        }
391
392        Ok(())
393    }
394}