Skip to main content

tycho_simulation/evm/protocol/etherfi/
state.rs

1use std::collections::HashMap;
2
3use alloy::primitives::U256;
4use hex_literal::hex;
5use num_bigint::{BigInt, BigUint};
6use num_traits::ToPrimitive;
7use serde::{Deserialize, Serialize};
8use tycho_common::{
9    dto::ProtocolStateDelta,
10    models::token::Token,
11    simulation::{
12        errors::{SimulationError, TransitionError},
13        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
14    },
15    Bytes,
16};
17
18use crate::evm::protocol::{
19    u256_num::{biguint_to_u256, u256_to_biguint, u256_to_f64},
20    utils::solidity_math::mul_div,
21};
22
23pub const EETH_ADDRESS: [u8; 20] = hex!("35fA164735182de50811E8e2E824cFb9B6118ac2");
24pub const WEETH_ADDRESS: [u8; 20] = hex!("Cd5fE23C85820F7B72D0926FC9b05b43E359b7ee");
25pub const ETH_ADDRESS: [u8; 20] = hex!("EeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE");
26pub const BASIS_POINT_SCALE: u64 = 10000;
27pub const BUCKET_UNIT_SCALE: u64 = 1_000_000_000_000;
28
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30pub struct EtherfiState {
31    block_timestamp: u64,
32    total_value_out_of_lp: U256,
33    total_value_in_lp: U256,
34    total_shares: U256,
35    eth_amount_locked_for_withdrawl: Option<U256>,
36    liquidity_pool_native_balance: Option<U256>,
37    eth_redemption_info: Option<RedemptionInfo>,
38}
39
40#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
41pub struct RedemptionInfo {
42    limit: BucketLimit,
43    exit_fee_split_to_treasury_in_bps: u16,
44    exit_fee_in_bps: u16,
45    low_watermark_in_bps_of_tvl: u16,
46}
47
48#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
49pub struct BucketLimit {
50    // The maximum capacity of the bucket, in consumable units (eg. tokens)
51    capacity: u64,
52    // The remaining capacity in the bucket, that can be consumed
53    remaining: u64,
54    // The timestamp of the last time the bucket was refilled
55    last_refill: u64,
56    // The rate at which the bucket refills, in units per second
57    refill_rate: u64,
58}
59
60impl EtherfiState {
61    pub fn new(
62        block_timestamp: u64,
63        total_value_out_of_lp: U256,
64        total_value_in_lp: U256,
65        total_shares: U256,
66        eth_amount_locked_for_withdrawl: Option<U256>,
67        eth_redemption_info: Option<RedemptionInfo>,
68        liquidity_pool_native_balance: Option<U256>,
69    ) -> Self {
70        EtherfiState {
71            block_timestamp,
72            total_value_out_of_lp,
73            total_value_in_lp,
74            total_shares,
75            eth_amount_locked_for_withdrawl,
76            liquidity_pool_native_balance,
77            eth_redemption_info,
78        }
79    }
80
81    fn require_redemption_info(&self) -> Result<RedemptionInfo, SimulationError> {
82        self.eth_redemption_info
83            .ok_or_else(|| SimulationError::FatalError("missing eth redemption info".to_string()))
84    }
85
86    fn require_liquidity_balance(&self) -> Result<U256, SimulationError> {
87        self.liquidity_pool_native_balance
88            .ok_or_else(|| {
89                SimulationError::FatalError("missing liquidity pool native balance".to_string())
90            })
91    }
92
93    fn require_eth_amount_locked_for_withdrawl(&self) -> Result<U256, SimulationError> {
94        self.eth_amount_locked_for_withdrawl
95            .ok_or_else(|| {
96                SimulationError::FatalError("missing eth amount locked for withdrawal".to_string())
97            })
98    }
99
100    fn shares_for_amount(&self, amount: U256) -> Result<U256, SimulationError> {
101        let total_pooled_ether = self.total_value_in_lp + self.total_value_out_of_lp;
102        if total_pooled_ether == U256::ZERO {
103            return Ok(U256::ZERO)
104        }
105        // Pro-rata shares for a given pooled ETH amount.
106        Ok(amount * self.total_shares / total_pooled_ether)
107    }
108
109    fn amount_for_share(&self, share: U256) -> Result<U256, SimulationError> {
110        if self.total_shares == U256::ZERO {
111            return Ok(U256::ZERO)
112        }
113        // Pro-rata ETH amount for a given share count.
114        let total_pooled_ether = self.total_value_in_lp + self.total_value_out_of_lp;
115        Ok(share * total_pooled_ether / self.total_shares)
116    }
117
118    fn shares_for_withdrawal_amount(&self, amount: U256) -> Result<U256, SimulationError> {
119        let total_pooled_ether = self.total_value_in_lp + self.total_value_out_of_lp;
120        if total_pooled_ether == U256::ZERO {
121            return Ok(U256::ZERO)
122        }
123        let numerator = amount * self.total_shares;
124        Ok(numerator + total_pooled_ether - U256::ONE / total_pooled_ether)
125    }
126}
127
128impl BucketLimit {
129    pub(crate) fn from_u256(value: U256) -> Self {
130        let mask = U256::from(u64::MAX);
131        Self {
132            capacity: (value & mask).to::<u64>(),
133            remaining: ((value >> 64u32) & mask).to::<u64>(),
134            last_refill: ((value >> 128u32) & mask).to::<u64>(),
135            refill_rate: ((value >> 192u32) & mask).to::<u64>(),
136        }
137    }
138
139    // Apply time-based refill to remaining capacity.
140    fn refill(mut self, now: u64) -> Self {
141        if now <= self.last_refill {
142            return self;
143        }
144        let delta = now - self.last_refill;
145        let tokens = (delta as u128) * (self.refill_rate as u128);
146        let new_remaining = (self.remaining as u128) + tokens;
147        if new_remaining > self.capacity as u128 {
148            self.remaining = self.capacity;
149        } else {
150            self.remaining = new_remaining as u64;
151        }
152        self.last_refill = now;
153        self
154    }
155}
156
157// Convert wei amount to bucket units with optional rounding up.
158fn convert_to_bucket_unit(amount: U256, rounding_up: bool) -> Result<u64, SimulationError> {
159    let scale = U256::from(BUCKET_UNIT_SCALE);
160    let max_amount = U256::from(u64::MAX) * scale;
161    if amount >= max_amount {
162        return Err(SimulationError::FatalError(
163            "EtherFiRedemptionManager: Amount too large".to_string(),
164        ));
165    }
166    // Convert wei to rate-limit bucket units with optional ceil/floor.
167    let bucket = if rounding_up { (amount + scale - U256::ONE) / scale } else { amount / scale };
168    if bucket > U256::from(u64::MAX) {
169        return Err(SimulationError::FatalError(
170            "EtherFiRedemptionManager: Amount too large".to_string(),
171        ));
172    }
173    Ok(bucket.to::<u64>())
174}
175
176impl RedemptionInfo {
177    pub(crate) fn from_u256(limit: BucketLimit, value: U256) -> Self {
178        let mask = U256::from(u64::from(u16::MAX));
179        let exit_fee_split_to_treasury_in_bps = (value & mask).to::<u16>();
180        let exit_fee_in_bps = ((value >> 16u32) & mask).to::<u16>();
181        let low_watermark_in_bps_of_tvl = ((value >> 32u32) & mask).to::<u16>();
182        Self {
183            limit,
184            exit_fee_split_to_treasury_in_bps,
185            exit_fee_in_bps,
186            low_watermark_in_bps_of_tvl,
187        }
188    }
189}
190
191#[typetag::serde]
192impl ProtocolSim for EtherfiState {
193    fn fee(&self) -> f64 {
194        0 as f64
195    }
196
197    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
198        let base_unit = U256::from(10).pow(U256::from(base.decimals));
199        let quote_unit = U256::from(10).pow(U256::from(quote.decimals));
200        let quote_unit_f64 = u256_to_f64(quote_unit)?;
201        let to_price = |amount_out: U256| -> Result<f64, SimulationError> {
202            Ok(u256_to_f64(amount_out)? / quote_unit_f64)
203        };
204
205        if base.address.as_ref() == EETH_ADDRESS && quote.address.as_ref() == WEETH_ADDRESS {
206            to_price(self.shares_for_amount(base_unit)?)
207        } else if base.address.as_ref() == WEETH_ADDRESS && quote.address.as_ref() == EETH_ADDRESS {
208            to_price(self.amount_for_share(base_unit)?)
209        } else if base.address.as_ref() == ETH_ADDRESS && quote.address.as_ref() == EETH_ADDRESS {
210            to_price(self.shares_for_amount(base_unit)?)
211        } else if base.address.as_ref() == EETH_ADDRESS && quote.address.as_ref() == ETH_ADDRESS {
212            to_price(self.amount_for_share(base_unit)?)
213        } else {
214            Err(SimulationError::FatalError("unsupported spot price".to_string()))
215        }
216    }
217
218    fn get_amount_out(
219        &self,
220        amount_in: BigUint,
221        token_in: &Token,
222        token_out: &Token,
223    ) -> Result<GetAmountOutResult, SimulationError> {
224        let mut new_state = self.clone();
225        let amount_in = biguint_to_u256(&amount_in);
226        if token_in.address.as_ref() == ETH_ADDRESS && token_out.address.as_ref() == EETH_ADDRESS {
227            // eth --> eeth
228            let amount_out = self.shares_for_amount(amount_in)?;
229            new_state.total_shares += amount_out;
230            new_state.total_value_in_lp += amount_in;
231            return Ok(GetAmountOutResult::new(
232                u256_to_biguint(amount_out),
233                BigUint::from(46_886u32), // LiquidityPool.deposit function gas used
234                Box::new(new_state),
235            ))
236        }
237
238        if token_in.address.as_ref() == EETH_ADDRESS && token_out.address.as_ref() == ETH_ADDRESS {
239            // eeth --> eth
240            let liquidity_pool_native_balance = self.require_liquidity_balance()?;
241            let eth_amount_locked_for_withdrawl = self.require_eth_amount_locked_for_withdrawl()?;
242            let eth_redemption_info = self.require_redemption_info()?;
243            let liquid_eth_amount = liquidity_pool_native_balance - eth_amount_locked_for_withdrawl;
244            let low_watermark = mul_div(
245                self.total_value_in_lp + self.total_value_out_of_lp,
246                U256::from(eth_redemption_info.low_watermark_in_bps_of_tvl),
247                U256::from(BASIS_POINT_SCALE),
248            )?;
249            if liquid_eth_amount < low_watermark || liquid_eth_amount - low_watermark < amount_in {
250                return Err(SimulationError::FatalError("Exceeded total redeemable amount".into()))
251            } else {
252                // Enforce the rate-limit bucket before applying exit fees and balances.
253                let bucket_unit = convert_to_bucket_unit(amount_in, true)?;
254                let mut limit = eth_redemption_info
255                    .limit
256                    .refill(self.block_timestamp);
257                if limit.remaining < bucket_unit {
258                    return Err(SimulationError::FatalError("Exceeded rate limit".into()))
259                }
260                limit.remaining -= bucket_unit;
261                limit.last_refill = self.block_timestamp;
262                let mut updated_info = eth_redemption_info;
263                updated_info.limit = limit;
264                new_state.eth_redemption_info = Some(updated_info);
265            }
266            let eeth_shares = self.shares_for_amount(amount_in)?;
267            let eth_amount_out = self.amount_for_share(mul_div(
268                eeth_shares,
269                U256::from(BASIS_POINT_SCALE) - U256::from(eth_redemption_info.exit_fee_in_bps),
270                U256::from(BASIS_POINT_SCALE),
271            )?)?;
272            new_state.total_value_in_lp -= eth_amount_out;
273            new_state.total_shares -= self.shares_for_withdrawal_amount(eth_amount_out)?;
274            new_state.liquidity_pool_native_balance =
275                Some(liquidity_pool_native_balance - eth_amount_out);
276            let amount_out = u256_to_biguint(eth_amount_out);
277            return Ok(GetAmountOutResult::new(
278                amount_out,
279                BigUint::from(151_676u32), /* EtherFiRedemptionManager._redeemEEth function gas
280                                            * used */
281                Box::new(new_state),
282            ))
283        }
284
285        if token_in.address.as_ref() == EETH_ADDRESS && token_out.address.as_ref() == WEETH_ADDRESS
286        {
287            // eeth --> weeth
288            let amount_out = u256_to_biguint(self.shares_for_amount(amount_in)?);
289            return Ok(GetAmountOutResult::new(
290                amount_out,
291                BigUint::from(70_489u32), // weeth.wrap function gas used
292                Box::new(new_state),
293            ))
294        }
295
296        if token_in.address.as_ref() == WEETH_ADDRESS && token_out.address.as_ref() == EETH_ADDRESS
297        {
298            // weeth --> eeth
299            let amount_out = u256_to_biguint(self.amount_for_share(amount_in)?);
300            return Ok(GetAmountOutResult::new(
301                amount_out,
302                BigUint::from(60_182u32), // weeth.unwrap function gas used
303                Box::new(new_state),
304            ))
305        }
306
307        Err(SimulationError::FatalError("unsupported swap".to_string()))
308    }
309
310    fn get_limits(
311        &self,
312        sell_token: Bytes,
313        buy_token: Bytes,
314    ) -> Result<(BigUint, BigUint), SimulationError> {
315        if sell_token.as_ref() == WEETH_ADDRESS && buy_token.as_ref() == EETH_ADDRESS {
316            let max_weeth_amount = self.shares_for_amount(self.total_shares)?;
317            let max_eeth_amount = self.total_shares;
318            return Ok((u256_to_biguint(max_weeth_amount), u256_to_biguint(max_eeth_amount)));
319        }
320
321        if sell_token.as_ref() == EETH_ADDRESS && buy_token.as_ref() == ETH_ADDRESS {
322            let liquidity_pool_native_balance = self.require_liquidity_balance()?;
323            let eth_amount_locked_for_withdrawl = self.require_eth_amount_locked_for_withdrawl()?;
324            let eth_redemption_info = self.require_redemption_info()?;
325            let liquid_eth_amount = liquidity_pool_native_balance - eth_amount_locked_for_withdrawl;
326            let low_watermark = mul_div(
327                self.total_value_in_lp + self.total_value_out_of_lp,
328                U256::from(eth_redemption_info.low_watermark_in_bps_of_tvl),
329                U256::from(BASIS_POINT_SCALE),
330            )?;
331            if liquid_eth_amount < low_watermark {
332                return Ok((u256_to_biguint(liquid_eth_amount), BigUint::ZERO));
333            }
334            let mut max_eeth_amount = self.total_value_in_lp + self.total_value_out_of_lp;
335            let limit = eth_redemption_info
336                .limit
337                .refill(self.block_timestamp);
338            // Cap max sell amount by the rate-limit bucket, in wei.
339            let bucket_unit = convert_to_bucket_unit(max_eeth_amount, true)?;
340            if limit.remaining < bucket_unit {
341                max_eeth_amount = U256::from(limit.remaining) * U256::from(BUCKET_UNIT_SCALE);
342            }
343            let eeth_shares = self.shares_for_amount(max_eeth_amount)?;
344            let eth_amount_out = self.amount_for_share(mul_div(
345                eeth_shares,
346                U256::from(BASIS_POINT_SCALE) - U256::from(eth_redemption_info.exit_fee_in_bps),
347                U256::from(BASIS_POINT_SCALE),
348            )?)?;
349            return Ok((u256_to_biguint(max_eeth_amount), u256_to_biguint(eth_amount_out)));
350        }
351
352        if sell_token.as_ref() == EETH_ADDRESS && buy_token.as_ref() == WEETH_ADDRESS {
353            return Ok((u256_to_biguint(U256::MAX), u256_to_biguint(U256::MAX)));
354        }
355
356        if sell_token.as_ref() == ETH_ADDRESS && buy_token.as_ref() == EETH_ADDRESS {
357            return Ok((u256_to_biguint(U256::MAX), u256_to_biguint(U256::MAX)));
358        }
359
360        Err(SimulationError::FatalError("unsupported swap".to_string()))
361    }
362
363    fn delta_transition(
364        &mut self,
365        delta: ProtocolStateDelta,
366        _tokens: &HashMap<Bytes, Token>,
367        _balances: &Balances,
368    ) -> Result<(), TransitionError<String>> {
369        if let Some(block_timestamp) = delta
370            .updated_attributes
371            .get("block_timestamp")
372        {
373            self.block_timestamp = BigInt::from_signed_bytes_be(block_timestamp)
374                .to_u64()
375                .unwrap();
376        }
377        if let Some(total_value_out_of_lp) = delta
378            .updated_attributes
379            .get("totalValueOutOfLp")
380        {
381            self.total_value_out_of_lp = U256::from_be_slice(total_value_out_of_lp);
382        }
383        if let Some(total_value_in_lp) = delta
384            .updated_attributes
385            .get("totalValueInLp")
386        {
387            self.total_value_in_lp = U256::from_be_slice(total_value_in_lp);
388        }
389        if let Some(total_shares) = delta
390            .updated_attributes
391            .get("totalShares")
392        {
393            self.total_shares = U256::from_be_slice(total_shares);
394        }
395        if let Some(eth_amount_locked_for_withdrawl) = delta
396            .updated_attributes
397            .get("ethAmountLockedForWithdrawl")
398        {
399            self.eth_amount_locked_for_withdrawl =
400                Some(U256::from_be_slice(eth_amount_locked_for_withdrawl));
401        }
402        if let Some(liquidity_pool_native_balance) = delta
403            .updated_attributes
404            .get("liquidity_pool_native_balance")
405        {
406            self.liquidity_pool_native_balance =
407                Some(U256::from_be_slice(liquidity_pool_native_balance));
408        }
409        let eth_bucket_limiter = delta
410            .updated_attributes
411            .get("ethBucketLimiter")
412            .map(|value| U256::from_be_slice(value));
413        let eth_redemption_info = delta
414            .updated_attributes
415            .get("ethRedemptionInfo")
416            .map(|value| U256::from_be_slice(value));
417        if eth_bucket_limiter.is_some() || eth_redemption_info.is_some() {
418            let existing = self
419                .eth_redemption_info
420                .unwrap_or_default();
421            let mut limit = existing.limit;
422            let mut exit_fee_split = existing.exit_fee_split_to_treasury_in_bps;
423            let mut exit_fee = existing.exit_fee_in_bps;
424            let mut low_watermark = existing.low_watermark_in_bps_of_tvl;
425
426            if let Some(eth_bucket_limiter) = eth_bucket_limiter {
427                limit = BucketLimit::from_u256(eth_bucket_limiter);
428            }
429            if let Some(eth_redemption_info) = eth_redemption_info {
430                let parsed = RedemptionInfo::from_u256(limit, eth_redemption_info);
431                limit = parsed.limit;
432                exit_fee_split = parsed.exit_fee_split_to_treasury_in_bps;
433                exit_fee = parsed.exit_fee_in_bps;
434                low_watermark = parsed.low_watermark_in_bps_of_tvl;
435            }
436
437            self.eth_redemption_info = Some(RedemptionInfo {
438                limit,
439                exit_fee_split_to_treasury_in_bps: exit_fee_split,
440                exit_fee_in_bps: exit_fee,
441                low_watermark_in_bps_of_tvl: low_watermark,
442            });
443        }
444
445        Ok(())
446    }
447
448    fn clone_box(&self) -> Box<dyn ProtocolSim> {
449        Box::new(self.clone())
450    }
451
452    fn as_any(&self) -> &dyn std::any::Any {
453        self
454    }
455
456    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
457        self
458    }
459
460    fn eq(&self, other: &dyn ProtocolSim) -> bool {
461        if let Some(other_state) = other.as_any().downcast_ref::<Self>() {
462            self == other_state
463        } else {
464            false
465        }
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    fn u256_dec(value: &str) -> U256 {
474        U256::from_str_radix(value, 10).expect("valid base-10 U256")
475    }
476
477    fn sample_state() -> EtherfiState {
478        EtherfiState {
479            block_timestamp: 1_764_901_727,
480            total_value_out_of_lp: u256_dec("2649956291248983147816190"),
481            total_value_in_lp: u256_dec("35878437939234433682319"),
482            total_shares: u256_dec("2479957712837255941780080"),
483            eth_amount_locked_for_withdrawl: Some(u256_dec("5572247384784800483589")),
484            liquidity_pool_native_balance: Some(u256_dec("35878437939234433682319")),
485            eth_redemption_info: Some(RedemptionInfo {
486                limit: BucketLimit {
487                    capacity: 2_000_000_000,
488                    remaining: 1_998_993_391,
489                    last_refill: 1_764_901_727,
490                    refill_rate: 23_148,
491                },
492                exit_fee_split_to_treasury_in_bps: 1000,
493                exit_fee_in_bps: 30,
494                low_watermark_in_bps_of_tvl: 100,
495            }),
496        }
497    }
498
499    #[test]
500    fn bucket_limit_from_u256_parses_fields() {
501        let capacity = 2_000_000_000u64;
502        let remaining = 1_999_995_000u64;
503        let last_refill = 1_767_694_523u64;
504        let refill_rate = 23_148u64;
505
506        let value = U256::from(capacity) |
507            (U256::from(remaining) << 64u32) |
508            (U256::from(last_refill) << 128u32) |
509            (U256::from(refill_rate) << 192u32);
510
511        let limit = BucketLimit::from_u256(value);
512        assert_eq!(limit.capacity, capacity);
513        assert_eq!(limit.remaining, remaining);
514        assert_eq!(limit.last_refill, last_refill);
515        assert_eq!(limit.refill_rate, refill_rate);
516    }
517
518    #[test]
519    fn redemption_info_from_u256_parses_fields() {
520        let limit = BucketLimit { capacity: 1, remaining: 2, last_refill: 3, refill_rate: 4 };
521        let exit_fee_split = 1000u16;
522        let exit_fee = 30u16;
523        let low_watermark = 100u16;
524
525        let value = U256::from(u64::from(exit_fee_split)) |
526            (U256::from(u64::from(exit_fee)) << 16u32) |
527            (U256::from(u64::from(low_watermark)) << 32u32);
528
529        let info = RedemptionInfo::from_u256(limit, value);
530        assert_eq!(info.limit, limit);
531        assert_eq!(info.exit_fee_split_to_treasury_in_bps, exit_fee_split);
532        assert_eq!(info.exit_fee_in_bps, exit_fee);
533        assert_eq!(info.low_watermark_in_bps_of_tvl, low_watermark);
534    }
535
536    #[test]
537    fn convert_to_bucket_unit_rounds_up() {
538        let amount = U256::from(BUCKET_UNIT_SCALE - 1);
539        let bucket = convert_to_bucket_unit(amount, true).expect("bucket");
540        assert_eq!(bucket, 1);
541
542        let exact = U256::from(BUCKET_UNIT_SCALE * 2);
543        let bucket_exact = convert_to_bucket_unit(exact, true).expect("bucket");
544        assert_eq!(bucket_exact, 2);
545    }
546
547    #[test]
548    fn convert_to_bucket_unit_rounds_down() {
549        let amount = U256::from(BUCKET_UNIT_SCALE - 1);
550        let bucket = convert_to_bucket_unit(amount, false).expect("bucket");
551        assert_eq!(bucket, 0);
552
553        let exact = U256::from(BUCKET_UNIT_SCALE * 3);
554        let bucket_exact = convert_to_bucket_unit(exact, false).expect("bucket");
555        assert_eq!(bucket_exact, 3);
556    }
557
558    #[test]
559    fn convert_to_bucket_unit_rejects_large_amounts() {
560        let scale = U256::from(BUCKET_UNIT_SCALE);
561        let max_amount = U256::from(u64::MAX) * scale;
562        let err = convert_to_bucket_unit(max_amount, true).unwrap_err();
563        match err {
564            SimulationError::FatalError(msg) => {
565                assert!(msg.contains("Amount too large"));
566            }
567            _ => panic!("unexpected error type"),
568        }
569    }
570
571    #[test]
572    fn bucket_limit_refill_caps_at_capacity() {
573        let limit = BucketLimit { capacity: 10, remaining: 1, last_refill: 100, refill_rate: 5 };
574        let refilled = limit.refill(103);
575        assert_eq!(refilled.remaining, 10);
576        assert_eq!(refilled.last_refill, 103);
577    }
578
579    #[test]
580    fn bucket_limit_refill_noop_same_or_older_time() {
581        let limit = BucketLimit { capacity: 10, remaining: 4, last_refill: 100, refill_rate: 5 };
582        let same = limit.refill(100);
583        assert_eq!(same.remaining, 4);
584        assert_eq!(same.last_refill, 100);
585
586        let older = limit.refill(99);
587        assert_eq!(older.remaining, 4);
588        assert_eq!(older.last_refill, 100);
589    }
590
591    #[test]
592    fn get_limits_eeth_to_eth_caps_by_bucket_remaining() {
593        let state = sample_state();
594        let info = state
595            .eth_redemption_info
596            .expect("redemption info");
597        let limit = info.limit.refill(state.block_timestamp);
598        let expected_max_in = U256::from(limit.remaining) * U256::from(BUCKET_UNIT_SCALE);
599
600        let (max_in, max_out) = state
601            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
602            .expect("limits");
603
604        assert_eq!(max_in, u256_to_biguint(expected_max_in));
605        let eeth_shares = state
606            .shares_for_amount(expected_max_in)
607            .expect("shares");
608        let net_shares = mul_div(
609            eeth_shares,
610            U256::from(BASIS_POINT_SCALE) - U256::from(info.exit_fee_in_bps),
611            U256::from(BASIS_POINT_SCALE),
612        )
613        .expect("mul_div");
614        let expected_out = state
615            .amount_for_share(net_shares)
616            .expect("amount");
617        assert_eq!(max_out, u256_to_biguint(expected_out));
618    }
619
620    #[test]
621    fn get_limits_eeth_to_eth_returns_liquid_amount_when_below_low_watermark() {
622        let mut state = sample_state();
623        let info = state
624            .eth_redemption_info
625            .expect("redemption info");
626        let total_pooled = state.total_value_in_lp + state.total_value_out_of_lp;
627        let low_watermark = mul_div(
628            total_pooled,
629            U256::from(info.low_watermark_in_bps_of_tvl),
630            U256::from(BASIS_POINT_SCALE),
631        )
632        .expect("low watermark");
633        let locked = state
634            .eth_amount_locked_for_withdrawl
635            .expect("locked");
636        state.liquidity_pool_native_balance = Some(locked + low_watermark - U256::ONE);
637
638        let (max_in, max_out) = state
639            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
640            .expect("limits");
641
642        assert_eq!(max_in, u256_to_biguint(low_watermark - U256::ONE));
643        assert_eq!(max_out, BigUint::ZERO);
644    }
645
646    #[test]
647    fn get_limits_weeth_to_eeth_uses_total_shares() {
648        let state = sample_state();
649        let max_weeth = state
650            .shares_for_amount(state.total_shares)
651            .expect("shares");
652        let (max_in, max_out) = state
653            .get_limits(Bytes::from(WEETH_ADDRESS), Bytes::from(EETH_ADDRESS))
654            .expect("limits");
655
656        assert_eq!(max_in, u256_to_biguint(max_weeth));
657        assert_eq!(max_out, u256_to_biguint(state.total_shares));
658    }
659}