Skip to main content

tycho_simulation/evm/protocol/etherfi/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use alloy::primitives::U256;
4use hex_literal::hex;
5use num_bigint::BigUint;
6use serde::{Deserialize, Serialize};
7use tycho_common::{
8    dto::ProtocolStateDelta,
9    models::token::Token,
10    simulation::{
11        errors::{SimulationError, TransitionError},
12        protocol_sim::{Balances, BlockContext, GetAmountOutResult, ProtocolSim},
13    },
14    Bytes,
15};
16
17use crate::evm::protocol::{
18    safe_math::{safe_add_u256, safe_sub_u256},
19    u256_num::{biguint_to_u256, u256_to_biguint, u256_to_f64},
20    utils::solidity_math::{mul_div, mul_div_rounding_up},
21};
22
23pub const EETH_ADDRESS: [u8; 20] = hex!("35fA164735182de50811E8e2E824cFb9B6118ac2");
24pub const WEETH_ADDRESS: [u8; 20] = hex!("Cd5fE23C85820F7B72D0926FC9b05b43E359b7ee");
25/// Native ETH as Tycho addresses it (`Chain::native_token`), not the router's 0xEeee..EEeE
26/// sentinel: this has to match the token the indexer reports and the token the swap encoder
27/// compares against.
28pub const ETH_ADDRESS: [u8; 20] = hex!("0000000000000000000000000000000000000000");
29
30/// Slice views of the addresses above, so a swap direction can be matched as a tuple.
31const ETH: &[u8] = &ETH_ADDRESS;
32const EETH: &[u8] = &EETH_ADDRESS;
33const WEETH: &[u8] = &WEETH_ADDRESS;
34
35/// The venue is two components: the LiquidityPool keyed by eETH, and the wrapper keyed by weETH.
36pub const POOL_COMPONENT_ID: &str = "0x35fa164735182de50811e8e2e824cfb9b6118ac2";
37pub const WRAPPER_COMPONENT_ID: &str = "0xcd5fe23c85820f7b72d0926fc9b05b43e359b7ee";
38
39// One attribute per value the protocol names. The substreams package unpacks the storage words,
40// so each of these carries a single scalar.
41pub const TOTAL_VALUE_OUT_OF_LP_ATTR: &str = "total_value_out_of_lp";
42pub const TOTAL_VALUE_IN_LP_ATTR: &str = "total_value_in_lp";
43pub const TOTAL_SHARES_ATTR: &str = "total_shares";
44pub const WEETH_SHARES_ATTR: &str = "weeth_shares";
45pub const EXIT_FEE_SPLIT_TO_TREASURY_BPS_ATTR: &str = "exit_fee_split_to_treasury_bps";
46pub const EXIT_FEE_BPS_ATTR: &str = "exit_fee_bps";
47pub const LOW_WATERMARK_BPS_ATTR: &str = "low_watermark_bps";
48
49/// The four attributes one `BucketLimiter.Limit` is reported as.
50pub struct BucketAttributes {
51    pub capacity: &'static str,
52    pub remaining: &'static str,
53    pub last_refill: &'static str,
54    pub refill_rate: &'static str,
55}
56
57/// `EtherFiRedemptionManager.tokenToRedemptionInfo(ETH).limit`, in units of 10^12 wei.
58pub const REDEMPTION_BUCKET: BucketAttributes = BucketAttributes {
59    capacity: "redemption_bucket_capacity",
60    remaining: "redemption_bucket_remaining",
61    last_refill: "redemption_bucket_last_refill",
62    refill_rate: "redemption_bucket_refill_rate",
63};
64/// `EtherFiRateLimiter.limits[EETH_MINT_LIMIT_ID]`, consumed by `eETH.mintShares`, in gwei.
65pub const MINT_BUCKET: BucketAttributes = BucketAttributes {
66    capacity: "mint_bucket_capacity",
67    remaining: "mint_bucket_remaining",
68    last_refill: "mint_bucket_last_refill",
69    refill_rate: "mint_bucket_refill_rate",
70};
71/// `EtherFiRateLimiter.limits[EETH_BURN_LIMIT_ID]`, consumed by `eETH.burnShares`, in gwei.
72pub const BURN_BUCKET: BucketAttributes = BucketAttributes {
73    capacity: "burn_bucket_capacity",
74    remaining: "burn_bucket_remaining",
75    last_refill: "burn_bucket_last_refill",
76    refill_rate: "burn_bucket_refill_rate",
77};
78
79const BASIS_POINT_SCALE: u64 = 10_000;
80/// `EtherFiRedemptionManager.BUCKET_UNIT_SCALE`.
81const REDEMPTION_BUCKET_UNIT: u64 = 1_000_000_000_000;
82/// `eETH._toBucketUnit` counts in gwei.
83const GWEI: u64 = 1_000_000_000;
84
85const DEPOSIT_GAS: u64 = 46_886;
86const REDEEM_GAS: u64 = 151_676;
87const WRAP_GAS: u64 = 70_489;
88const UNWRAP_GAS: u64 = 60_182;
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct EtherfiState {
92    /// Unix timestamp of the block a quote is expected to execute in, maintained by
93    /// `apply_block`.
94    ///
95    /// Every rate-limit bucket refills per second, so this tracks the chain head: `apply_block`
96    /// advances it on every message, including the blocks where EtherFi storage does not move.
97    execution_block_timestamp: u64,
98    /// `LiquidityPool.totalValueOutOfLp`: ether staked on the consensus layer.
99    total_value_out_of_lp: U256,
100    /// `LiquidityPool.totalValueInLp`: ether the pool holds, which redemptions are paid from.
101    total_value_in_lp: U256,
102    /// `eETH.totalShares`.
103    total_shares: U256,
104    venue: Venue,
105}
106
107/// Which of the two components this state is, and the state only that component needs.
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub enum Venue {
110    /// The eETH component: `LiquidityPool.deposit` and `EtherFiRedemptionManager.redeemEEth`.
111    Pool(PoolState),
112    /// The weETH component: `wrap` and `unwrap`.
113    Wrapper(WrapperState),
114}
115
116#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub struct PoolState {
118    pub redemption: RedemptionInfo,
119    /// Bounds deposits: `eETH.mintShares` consumes the minted balance from it, in gwei.
120    pub mint_limit: BucketLimit,
121    /// Bounds redemptions: `eETH.burnShares` consumes the burnt balance from it, in gwei.
122    pub burn_limit: BucketLimit,
123}
124
125#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
126pub struct WrapperState {
127    /// `eETH.shares(weETH)`: the shares the wrapper holds. Unwrapping pays out of them.
128    pub weeth_shares: U256,
129}
130
131/// `EtherFiRedemptionManager.RedemptionInfo` for ETH.
132#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
133pub struct RedemptionInfo {
134    /// In units of `REDEMPTION_BUCKET_UNIT` wei, consumed by the eETH amount redeemed.
135    pub limit: BucketLimit,
136    pub exit_fee_split_to_treasury_bps: u16,
137    pub exit_fee_bps: u16,
138    /// Redemptions may not take `totalValueInLp` below this share of `getTotalPooledEther()`.
139    pub low_watermark_bps: u16,
140}
141
142impl RedemptionInfo {
143    /// The share of a redemption that reaches the redeemer, in basis points.
144    ///
145    /// `EtherFiRedemptionManager` rejects an exit fee above the basis-point scale, so one here
146    /// came off the wire malformed rather than off the chain, and subtracting it would wrap.
147    fn net_of_exit_fee_bps(&self) -> Result<u64, SimulationError> {
148        BASIS_POINT_SCALE
149            .checked_sub(u64::from(self.exit_fee_bps))
150            .ok_or_else(|| {
151                SimulationError::FatalError(format!(
152                    "exit fee of {} bps exceeds the basis-point scale",
153                    self.exit_fee_bps
154                ))
155            })
156    }
157}
158
159/// `BucketLimiter.Limit`: a token bucket that refills at `refill_rate` units per second up to
160/// `capacity`.
161#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct BucketLimit {
163    pub capacity: u64,
164    pub remaining: u64,
165    pub last_refill: u64,
166    pub refill_rate: u64,
167}
168
169impl BucketLimit {
170    /// `BucketLimiter._refill`: the bucket as it stands at `now`.
171    ///
172    /// A block older than the last refill leaves the bucket as it is; the contract's unchecked
173    /// subtraction would wrap there, but the chain never presents such a block.
174    fn refilled(self, now: u64) -> Self {
175        if now <= self.last_refill {
176            return self;
177        }
178        let elapsed = u128::from(now - self.last_refill);
179        let refilled = u128::from(self.remaining) + elapsed * u128::from(self.refill_rate);
180        let remaining = u64::try_from(refilled.min(u128::from(self.capacity)))
181            .expect("bounded by the u64 capacity");
182        Self { remaining, last_refill: now, ..self }
183    }
184
185    /// `BucketLimiter.consumable`: what can be drawn at `now`.
186    fn consumable(self, now: u64) -> u64 {
187        self.refilled(now).remaining
188    }
189
190    /// `BucketLimiter.consume`: the bucket after drawing `units` at `now`, or `None` when the
191    /// bucket cannot cover them.
192    fn consume(self, units: u64, now: u64) -> Option<Self> {
193        let refilled = self.refilled(now);
194        let remaining = refilled.remaining.checked_sub(units)?;
195        Some(Self { remaining, ..refilled })
196    }
197}
198
199/// `eETH._toBucketUnit`: gwei, rounded up, saturating at `u64::MAX`.
200fn gwei_units(amount: U256) -> u64 {
201    let units = amount.div_ceil(U256::from(GWEI));
202    u64::try_from(units).unwrap_or(u64::MAX)
203}
204
205/// `EtherFiRedemptionManager._convertToBucketUnit` rounding up. The contract rejects amounts of
206/// `u64::MAX` units or more outright.
207fn redemption_units(amount: U256) -> Result<u64, SimulationError> {
208    let scale = U256::from(REDEMPTION_BUCKET_UNIT);
209    if amount >= U256::from(u64::MAX) * scale {
210        return Err(SimulationError::RecoverableError("AMOUNT_TOO_LARGE".to_string()));
211    }
212    Ok(amount.div_ceil(scale).to::<u64>())
213}
214
215/// Bytes an attribute occupies on the wire: the width of the storage field the substreams
216/// package unpacks it from. The balances are `uint128` halves, the share counts whole words, the
217/// bucket fields `uint64` and the basis-point fields `uint16`.
218fn attribute_width(name: &str) -> Option<usize> {
219    if name == TOTAL_VALUE_OUT_OF_LP_ATTR || name == TOTAL_VALUE_IN_LP_ATTR {
220        return Some(16);
221    }
222    if name == TOTAL_SHARES_ATTR || name == WEETH_SHARES_ATTR {
223        return Some(32);
224    }
225    if name == EXIT_FEE_SPLIT_TO_TREASURY_BPS_ATTR ||
226        name == EXIT_FEE_BPS_ATTR ||
227        name == LOW_WATERMARK_BPS_ATTR
228    {
229        return Some(2);
230    }
231    for bucket in [&REDEMPTION_BUCKET, &MINT_BUCKET, &BURN_BUCKET] {
232        if [bucket.capacity, bucket.remaining, bucket.last_refill, bucket.refill_rate]
233            .contains(&name)
234        {
235            return Some(8);
236        }
237    }
238    None
239}
240
241/// Reads a big-endian attribute, refusing one wider than its storage field. The package emits
242/// minimal-length values, so a wider one is malformed, and the `Err` names it for the caller to
243/// report.
244pub(super) fn decode_attribute(name: &str, value: &[u8]) -> Result<U256, String> {
245    let Some(width) = attribute_width(name) else {
246        return Err(format!("{name} is not an EtherFi attribute"));
247    };
248    if value.len() > width {
249        return Err(format!("{name} is {} bytes, wider than its {width}-byte field", value.len()));
250    }
251    Ok(U256::from_be_slice(value))
252}
253
254/// `decode_attribute` for the `uint64` bucket fields.
255pub(super) fn decode_u64_attribute(name: &str, value: &[u8]) -> Result<u64, String> {
256    let value = decode_attribute(name, value)?;
257    u64::try_from(value).map_err(|_| format!("{name} does not fit in 64 bits"))
258}
259
260/// `decode_attribute` for the `uint16` basis-point fields.
261pub(super) fn decode_u16_attribute(name: &str, value: &[u8]) -> Result<u16, String> {
262    let value = decode_attribute(name, value)?;
263    u16::try_from(value).map_err(|_| format!("{name} does not fit in 16 bits"))
264}
265
266impl EtherfiState {
267    pub fn new(
268        execution_block_timestamp: u64,
269        total_value_out_of_lp: U256,
270        total_value_in_lp: U256,
271        total_shares: U256,
272        venue: Venue,
273    ) -> Self {
274        Self {
275            execution_block_timestamp,
276            total_value_out_of_lp,
277            total_value_in_lp,
278            total_shares,
279            venue,
280        }
281    }
282
283    /// `LiquidityPool.getTotalPooledEther()`.
284    fn total_pooled_ether(&self) -> Result<U256, SimulationError> {
285        safe_add_u256(self.total_value_out_of_lp, self.total_value_in_lp)
286    }
287
288    /// `LiquidityPool.sharesForAmount`, rounded down.
289    fn shares_for_amount(&self, amount: U256) -> Result<U256, SimulationError> {
290        let total_pooled_ether = self.total_pooled_ether()?;
291        if total_pooled_ether.is_zero() {
292            return Ok(U256::ZERO);
293        }
294        mul_div(amount, self.total_shares, total_pooled_ether)
295    }
296
297    /// `LiquidityPool.amountForShare`, rounded down.
298    fn amount_for_share(&self, share: U256) -> Result<U256, SimulationError> {
299        if self.total_shares.is_zero() {
300            return Ok(U256::ZERO);
301        }
302        mul_div(share, self.total_pooled_ether()?, self.total_shares)
303    }
304
305    /// `LiquidityPool.sharesForWithdrawalAmount`: rounded up, so rounding favours the pool.
306    fn shares_for_withdrawal_amount(&self, amount: U256) -> Result<U256, SimulationError> {
307        let total_pooled_ether = self.total_pooled_ether()?;
308        if total_pooled_ether.is_zero() {
309            return Ok(U256::ZERO);
310        }
311        mul_div_rounding_up(amount, self.total_shares, total_pooled_ether)
312    }
313
314    /// `EtherFiRedemptionManager.lowWatermarkInETH(ETH)`: the liquidity redemptions must leave.
315    fn low_watermark(&self, pool: &PoolState) -> Result<U256, SimulationError> {
316        mul_div(
317            self.total_pooled_ether()?,
318            U256::from(pool.redemption.low_watermark_bps),
319            U256::from(BASIS_POINT_SCALE),
320        )
321    }
322
323    /// ETH -> eETH through `LiquidityPool.deposit`.
324    ///
325    /// The deposit is credited to `totalValueInLp`, a `uint128`, and mints shares at the
326    /// pre-deposit rate. The depositor's eETH balance is those shares at the post-deposit rate,
327    /// which is what the caller trades; `eETH.mintShares` draws that balance from the mint bucket.
328    fn amount_out_eth_to_eeth(
329        &self,
330        pool: &PoolState,
331        amount_in: U256,
332    ) -> Result<GetAmountOutResult, SimulationError> {
333        let total_value_in_lp = safe_add_u256(self.total_value_in_lp, amount_in)?;
334        if total_value_in_lp > U256::from(u128::MAX) {
335            return Err(SimulationError::RecoverableError("LIQUIDITY_POOL_CAPACITY".to_string()));
336        }
337        let shares = self.shares_for_amount(amount_in)?;
338        if shares.is_zero() {
339            return Err(SimulationError::RecoverableError("ZERO_AMOUNT".to_string()));
340        }
341
342        let mut next = self.clone();
343        next.total_value_in_lp = total_value_in_lp;
344        next.total_shares = safe_add_u256(self.total_shares, shares)?;
345        let amount_out = next.amount_for_share(shares)?;
346
347        let mut pool = *pool;
348        pool.mint_limit = pool
349            .mint_limit
350            .consume(gwei_units(amount_out), self.execution_block_timestamp)
351            .ok_or_else(|| SimulationError::RecoverableError("MINT_RATE_LIMIT".to_string()))?;
352        next.venue = Venue::Pool(pool);
353
354        Ok(GetAmountOutResult::new(
355            u256_to_biguint(amount_out),
356            BigUint::from(DEPOSIT_GAS),
357            Box::new(next),
358        ))
359    }
360
361    fn redemption_amounts(
362        &self,
363        pool: &PoolState,
364        amount_in: U256,
365    ) -> Result<(U256, U256), SimulationError> {
366        let eeth_shares = self.shares_for_amount(amount_in)?;
367        let net_shares = mul_div(
368            eeth_shares,
369            U256::from(pool.redemption.net_of_exit_fee_bps()?),
370            U256::from(BASIS_POINT_SCALE),
371        )?;
372        let amount_out = self.amount_for_share(net_shares)?;
373        Ok((eeth_shares, amount_out))
374    }
375
376    /// eETH -> ETH through `EtherFiRedemptionManager.redeemEEth`.
377    ///
378    /// `canRedeem` bounds the amount by the liquidity above the low watermark and by the
379    /// redemption bucket. `_calcRedemption` then pays the shares net of the exit fee: the fee
380    /// shares split between the treasury, which keeps them, and the stakers, whose part is burnt
381    /// and so raises the share rate. `eETH.burnShares` draws both burns from the burn bucket.
382    fn amount_out_eeth_to_eth(
383        &self,
384        pool: &PoolState,
385        amount_in: U256,
386    ) -> Result<GetAmountOutResult, SimulationError> {
387        let liquid = self.total_value_in_lp;
388        let low_watermark = self.low_watermark(pool)?;
389        if liquid < low_watermark || safe_sub_u256(liquid, low_watermark)? < amount_in {
390            return Err(SimulationError::RecoverableError("EXCEEDED_REDEEMABLE".to_string()));
391        }
392        let mut pool = *pool;
393        pool.redemption.limit = pool
394            .redemption
395            .limit
396            .consume(redemption_units(amount_in)?, self.execution_block_timestamp)
397            .ok_or_else(|| {
398                SimulationError::RecoverableError("REDEMPTION_RATE_LIMIT".to_string())
399            })?;
400
401        let (eeth_shares, amount_out) = self.redemption_amounts(&pool, amount_in)?;
402        if amount_out.is_zero() {
403            return Err(SimulationError::RecoverableError("ZERO_AMOUNT".to_string()));
404        }
405        let shares_to_burn = self.shares_for_withdrawal_amount(amount_out)?;
406        let fee_shares = safe_sub_u256(eeth_shares, shares_to_burn)?;
407        let fee_shares_to_treasury = mul_div(
408            fee_shares,
409            U256::from(
410                pool.redemption
411                    .exit_fee_split_to_treasury_bps,
412            ),
413            U256::from(BASIS_POINT_SCALE),
414        )?;
415        let fee_shares_to_stakers = safe_sub_u256(fee_shares, fee_shares_to_treasury)?;
416
417        // `burnShares` values each burn after removing its shares. The withdrawal first
418        // reduces pooled ether; the stakers' fee then raises the rate for the second burn.
419        let mut next = self.clone();
420        next.total_value_in_lp = safe_sub_u256(liquid, amount_out)?;
421        next.total_shares = safe_sub_u256(self.total_shares, shares_to_burn)?;
422        pool.burn_limit = pool
423            .burn_limit
424            .consume(
425                gwei_units(next.amount_for_share(shares_to_burn)?),
426                self.execution_block_timestamp,
427            )
428            .ok_or_else(|| SimulationError::RecoverableError("BURN_RATE_LIMIT".to_string()))?;
429        next.total_shares = safe_sub_u256(next.total_shares, fee_shares_to_stakers)?;
430        pool.burn_limit = pool
431            .burn_limit
432            .consume(
433                gwei_units(next.amount_for_share(fee_shares_to_stakers)?),
434                self.execution_block_timestamp,
435            )
436            .ok_or_else(|| SimulationError::RecoverableError("BURN_RATE_LIMIT".to_string()))?;
437        next.venue = Venue::Pool(pool);
438
439        Ok(GetAmountOutResult::new(
440            u256_to_biguint(amount_out),
441            BigUint::from(REDEEM_GAS),
442            Box::new(next),
443        ))
444    }
445
446    /// eETH -> weETH through `weETH.wrap`: mints the shares the eETH is worth, and the wrapper
447    /// takes those shares in.
448    fn amount_out_eeth_to_weeth(
449        &self,
450        wrapper: &WrapperState,
451        amount_in: U256,
452    ) -> Result<GetAmountOutResult, SimulationError> {
453        let shares = self.shares_for_amount(amount_in)?;
454        if shares.is_zero() {
455            return Err(SimulationError::RecoverableError("ZERO_AMOUNT".to_string()));
456        }
457        let mut next = self.clone();
458        next.venue = Venue::Wrapper(WrapperState {
459            weeth_shares: safe_add_u256(wrapper.weeth_shares, shares)?,
460        });
461        Ok(GetAmountOutResult::new(
462            u256_to_biguint(shares),
463            BigUint::from(WRAP_GAS),
464            Box::new(next),
465        ))
466    }
467
468    /// weETH -> eETH through `weETH.unwrap`: pays the eETH the shares are worth out of the
469    /// wrapper's own balance. `eETH.transfer` moves the shares that amount is worth, which
470    /// rounding can leave a share short of the weETH burnt.
471    fn amount_out_weeth_to_eeth(
472        &self,
473        wrapper: &WrapperState,
474        amount_in: U256,
475    ) -> Result<GetAmountOutResult, SimulationError> {
476        if amount_in > wrapper.weeth_shares {
477            return Err(SimulationError::RecoverableError("WRAPPER_BALANCE_EXCEEDED".to_string()));
478        }
479        let amount_out = self.amount_for_share(amount_in)?;
480        if amount_out.is_zero() {
481            return Err(SimulationError::RecoverableError("ZERO_AMOUNT".to_string()));
482        }
483        let shares_moved = self.shares_for_amount(amount_out)?;
484        let mut next = self.clone();
485        next.venue = Venue::Wrapper(WrapperState {
486            weeth_shares: safe_sub_u256(wrapper.weeth_shares, shares_moved)?,
487        });
488        Ok(GetAmountOutResult::new(
489            u256_to_biguint(amount_out),
490            BigUint::from(UNWRAP_GAS),
491            Box::new(next),
492        ))
493    }
494
495    /// What each bucket can pay at `now`; `None` for the wrapper, which has no bucket.
496    fn consumable_units(&self, now: u64) -> Option<[u64; 3]> {
497        match &self.venue {
498            Venue::Pool(pool) => Some([
499                pool.redemption.limit.consumable(now),
500                pool.mint_limit.consumable(now),
501                pool.burn_limit.consumable(now),
502            ]),
503            Venue::Wrapper(_) => None,
504        }
505    }
506}
507
508/// The values one delta carries for a component, decoded but not yet applied.
509enum VenueFields {
510    Pool {
511        redemption_bucket: [Option<u64>; 4],
512        mint_bucket: [Option<u64>; 4],
513        burn_bucket: [Option<u64>; 4],
514        exit_fee_split_to_treasury_bps: Option<u16>,
515        exit_fee_bps: Option<u16>,
516        low_watermark_bps: Option<u16>,
517    },
518    Wrapper {
519        weeth_shares: Option<U256>,
520    },
521}
522
523#[typetag::serde]
524impl ProtocolSim for EtherfiState {
525    /// Zero, for both components.
526    ///
527    /// The wrapper charges nothing in either direction. The pool charges the exit fee on
528    /// `eETH -> ETH` and nothing on `ETH -> eETH`, and this interface carries one value with no
529    /// direction to attach it to, so the exit fee reaches callers through `spot_price` and
530    /// `get_amount_out`, which both apply it.
531    fn fee(&self) -> f64 {
532        0f64
533    }
534
535    /// Prices exactly the direction pairs each component performs.
536    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
537        let quote_unit_f64 = u256_to_f64(U256::from(10).pow(U256::from(quote.decimals)))?;
538        let base_unit = U256::from(10).pow(U256::from(base.decimals));
539        let to_price = |amount_out: U256| -> Result<f64, SimulationError> {
540            Ok(u256_to_f64(amount_out)? / quote_unit_f64)
541        };
542
543        match (&self.venue, base.address.as_ref(), quote.address.as_ref()) {
544            // Depositing mints shares worth the ETH, and the depositor holds the eETH balance
545            // those shares are worth: near parity, not the share count.
546            (Venue::Pool(_), ETH, EETH) => {
547                to_price(self.amount_for_share(self.shares_for_amount(base_unit)?)?)
548            }
549            // Redeeming pays the shares net of the exit fee.
550            (Venue::Pool(pool), EETH, ETH) => {
551                let net_shares = mul_div(
552                    self.shares_for_amount(base_unit)?,
553                    U256::from(pool.redemption.net_of_exit_fee_bps()?),
554                    U256::from(BASIS_POINT_SCALE),
555                )?;
556                to_price(self.amount_for_share(net_shares)?)
557            }
558            (Venue::Wrapper(_), EETH, WEETH) => to_price(self.shares_for_amount(base_unit)?),
559            (Venue::Wrapper(_), WEETH, EETH) => to_price(self.amount_for_share(base_unit)?),
560            _ => Err(SimulationError::FatalError("unsupported spot price".to_string())),
561        }
562    }
563
564    fn get_amount_out(
565        &self,
566        amount_in: BigUint,
567        token_in: &Token,
568        token_out: &Token,
569    ) -> Result<GetAmountOutResult, SimulationError> {
570        let amount_in = biguint_to_u256(&amount_in);
571        // Every entry point reverts on a zero amount.
572        if amount_in.is_zero() {
573            return Err(SimulationError::RecoverableError("ZERO_AMOUNT".to_string()));
574        }
575
576        match (&self.venue, token_in.address.as_ref(), token_out.address.as_ref()) {
577            (Venue::Pool(pool), ETH, EETH) => self.amount_out_eth_to_eeth(pool, amount_in),
578            (Venue::Pool(pool), EETH, ETH) => self.amount_out_eeth_to_eth(pool, amount_in),
579            (Venue::Wrapper(wrapper), EETH, WEETH) => {
580                self.amount_out_eeth_to_weeth(wrapper, amount_in)
581            }
582            (Venue::Wrapper(wrapper), WEETH, EETH) => {
583                self.amount_out_weeth_to_eeth(wrapper, amount_in)
584            }
585            _ => Err(SimulationError::FatalError("unsupported swap".to_string())),
586        }
587    }
588
589    /// A conservative sell bound each direction settles, and what it pays. A pair this component
590    /// does not hold is an error.
591    fn get_limits(
592        &self,
593        sell_token: Bytes,
594        buy_token: Bytes,
595    ) -> Result<(BigUint, BigUint), SimulationError> {
596        let now = self.execution_block_timestamp;
597        match (&self.venue, sell_token.as_ref(), buy_token.as_ref()) {
598            // Bounded by the mint bucket and by the `uint128` `totalValueInLp` is stored in.
599            (Venue::Pool(pool), ETH, EETH) => {
600                let headroom = U256::from(u128::MAX).saturating_sub(self.total_value_in_lp);
601                let mint = U256::from(pool.mint_limit.consumable(now)) * U256::from(GWEI);
602                let max_in = headroom.min(mint);
603                if max_in.is_zero() {
604                    return Ok((BigUint::ZERO, BigUint::ZERO));
605                }
606                let max_out = self
607                    .amount_out_eth_to_eeth(pool, max_in)?
608                    .amount;
609                Ok((u256_to_biguint(max_in), max_out))
610            }
611            // Bounded by the liquidity above the low watermark, the redemption bucket and the
612            // burn bucket. Below the watermark nothing is redeemable, so capacity is zero.
613            (Venue::Pool(pool), EETH, ETH) => {
614                let low_watermark = self.low_watermark(pool)?;
615                if self.total_value_in_lp <= low_watermark {
616                    return Ok((BigUint::ZERO, BigUint::ZERO));
617                }
618                // With pooled ether P and input x, both burns together cost at most
619                // x*P/(P-x): at most x*S/P shares burn, and both use at least the final
620                // remaining shares as denominator. Reserve one bucket unit for the two
621                // separate round-ups, then solve x*P/(P-x) <= burn_budget directly.
622                let burn_budget = U256::from(
623                    pool.burn_limit
624                        .consumable(now)
625                        .saturating_sub(1),
626                ) * U256::from(GWEI);
627                let pooled = self.total_pooled_ether()?;
628                let burn_max = mul_div(burn_budget, pooled, safe_add_u256(pooled, burn_budget)?)?;
629                let max_in = (self.total_value_in_lp - low_watermark)
630                    .min(
631                        U256::from(pool.redemption.limit.consumable(now)) *
632                            U256::from(REDEMPTION_BUCKET_UNIT),
633                    )
634                    .min(burn_max);
635                if max_in.is_zero() ||
636                    self.redemption_amounts(pool, max_in)?
637                        .1
638                        .is_zero()
639                {
640                    return Ok((BigUint::ZERO, BigUint::ZERO));
641                }
642                let max_out = self
643                    .amount_out_eeth_to_eth(pool, max_in)?
644                    .amount;
645                Ok((u256_to_biguint(max_in), max_out))
646            }
647
648            // Wrapping is not capped by the protocol; no more eETH can be wrapped than exists
649            // outside the wrapper.
650            (Venue::Wrapper(wrapper), EETH, WEETH) => {
651                let max_in = self
652                    .total_pooled_ether()?
653                    .saturating_sub(self.amount_for_share(wrapper.weeth_shares)?);
654                if max_in.is_zero() {
655                    return Ok((BigUint::ZERO, BigUint::ZERO));
656                }
657                Ok((u256_to_biguint(max_in), u256_to_biguint(self.shares_for_amount(max_in)?)))
658            }
659            // Unwrapping pays out of the eETH the wrapper holds, so the wrapper's shares bound it.
660            (Venue::Wrapper(wrapper), WEETH, EETH) => {
661                if wrapper.weeth_shares.is_zero() {
662                    return Ok((BigUint::ZERO, BigUint::ZERO));
663                }
664                Ok((
665                    u256_to_biguint(wrapper.weeth_shares),
666                    u256_to_biguint(self.amount_for_share(wrapper.weeth_shares)?),
667                ))
668            }
669            _ => Err(SimulationError::FatalError("unsupported swap".to_string())),
670        }
671    }
672
673    fn delta_transition(
674        &mut self,
675        delta: ProtocolStateDelta,
676        _tokens: &HashMap<Bytes, Token>,
677        _balances: &Balances,
678    ) -> Result<(), TransitionError> {
679        let attributes = &delta.updated_attributes;
680        let read = |name: &str| -> Result<Option<U256>, TransitionError> {
681            attributes
682                .get(name)
683                .map(|value| decode_attribute(name, value))
684                .transpose()
685                .map_err(TransitionError::DecodeError)
686        };
687        let read_u64 = |name: &str| -> Result<Option<u64>, TransitionError> {
688            attributes
689                .get(name)
690                .map(|value| decode_u64_attribute(name, value))
691                .transpose()
692                .map_err(TransitionError::DecodeError)
693        };
694        let read_u16 = |name: &str| -> Result<Option<u16>, TransitionError> {
695            attributes
696                .get(name)
697                .map(|value| decode_u16_attribute(name, value))
698                .transpose()
699                .map_err(TransitionError::DecodeError)
700        };
701        let read_bucket = |names: &BucketAttributes| -> Result<[Option<u64>; 4], TransitionError> {
702            Ok([
703                read_u64(names.capacity)?,
704                read_u64(names.remaining)?,
705                read_u64(names.last_refill)?,
706                read_u64(names.refill_rate)?,
707            ])
708        };
709        fn apply_bucket(bucket: &mut BucketLimit, fields: [Option<u64>; 4]) {
710            let [capacity, remaining, last_refill, refill_rate] = fields;
711            if let Some(value) = capacity {
712                bucket.capacity = value;
713            }
714            if let Some(value) = remaining {
715                bucket.remaining = value;
716            }
717            if let Some(value) = last_refill {
718                bucket.last_refill = value;
719            }
720            if let Some(value) = refill_rate {
721                bucket.refill_rate = value;
722            }
723        }
724
725        // Every read that can fail happens before the first assignment, so a delta the decoder
726        // rejects leaves the state as it was. Each component reads only its own attributes, and
727        // `venue_fields` is decoded against `self.venue`, so the venue arms below only assign.
728        let total_value_out_of_lp = read(TOTAL_VALUE_OUT_OF_LP_ATTR)?;
729        let total_value_in_lp = read(TOTAL_VALUE_IN_LP_ATTR)?;
730        let total_shares = read(TOTAL_SHARES_ATTR)?;
731        let venue_fields = match &self.venue {
732            Venue::Pool(_) => VenueFields::Pool {
733                redemption_bucket: read_bucket(&REDEMPTION_BUCKET)?,
734                mint_bucket: read_bucket(&MINT_BUCKET)?,
735                burn_bucket: read_bucket(&BURN_BUCKET)?,
736                exit_fee_split_to_treasury_bps: read_u16(EXIT_FEE_SPLIT_TO_TREASURY_BPS_ATTR)?,
737                exit_fee_bps: read_u16(EXIT_FEE_BPS_ATTR)?,
738                low_watermark_bps: read_u16(LOW_WATERMARK_BPS_ATTR)?,
739            },
740            Venue::Wrapper(_) => VenueFields::Wrapper { weeth_shares: read(WEETH_SHARES_ATTR)? },
741        };
742
743        if let Some(value) = total_value_out_of_lp {
744            self.total_value_out_of_lp = value;
745        }
746        if let Some(value) = total_value_in_lp {
747            self.total_value_in_lp = value;
748        }
749        if let Some(value) = total_shares {
750            self.total_shares = value;
751        }
752        match (&mut self.venue, venue_fields) {
753            (
754                Venue::Pool(pool),
755                VenueFields::Pool {
756                    redemption_bucket,
757                    mint_bucket,
758                    burn_bucket,
759                    exit_fee_split_to_treasury_bps,
760                    exit_fee_bps,
761                    low_watermark_bps,
762                },
763            ) => {
764                apply_bucket(&mut pool.redemption.limit, redemption_bucket);
765                apply_bucket(&mut pool.mint_limit, mint_bucket);
766                apply_bucket(&mut pool.burn_limit, burn_bucket);
767                if let Some(value) = exit_fee_split_to_treasury_bps {
768                    pool.redemption
769                        .exit_fee_split_to_treasury_bps = value;
770                }
771                if let Some(value) = exit_fee_bps {
772                    pool.redemption.exit_fee_bps = value;
773                }
774                if let Some(value) = low_watermark_bps {
775                    pool.redemption.low_watermark_bps = value;
776                }
777            }
778            (Venue::Wrapper(wrapper), VenueFields::Wrapper { weeth_shares }) => {
779                if let Some(value) = weeth_shares {
780                    wrapper.weeth_shares = value;
781                }
782            }
783            // Unreachable: `venue_fields` was decoded against `self.venue`, which nothing above
784            // changes. The arm exists so the match is exhaustive without a wildcard.
785            (Venue::Pool(_), VenueFields::Wrapper { .. }) |
786            (Venue::Wrapper(_), VenueFields::Pool { .. }) => {
787                return Err(TransitionError::DecodeError(
788                    "the decoded attributes belong to the other component".to_string(),
789                ))
790            }
791        }
792        Ok(())
793    }
794
795    /// Advances to the block a quote would execute in, so the three buckets keep refilling on
796    /// the blocks where EtherFi's own storage did not move.
797    ///
798    /// Re-emits only when a bucket's capacity actually changed: a repeated block short-circuits,
799    /// the wrapper has nothing time-dependent, and full buckets stay full.
800    fn apply_block(&mut self, block: &BlockContext) -> bool {
801        let timestamp = block.timestamp();
802        if timestamp == self.execution_block_timestamp {
803            return false;
804        }
805        let before = self.consumable_units(self.execution_block_timestamp);
806        self.execution_block_timestamp = timestamp;
807        before != self.consumable_units(timestamp)
808    }
809
810    fn query_pool_swap(
811        &self,
812        params: &tycho_common::simulation::protocol_sim::QueryPoolSwapParams,
813    ) -> Result<tycho_common::simulation::protocol_sim::PoolSwap, SimulationError> {
814        crate::evm::query_pool_swap::query_pool_swap(self, params)
815    }
816
817    fn clone_box(&self) -> Box<dyn ProtocolSim> {
818        Box::new(self.clone())
819    }
820
821    fn as_any(&self) -> &dyn Any {
822        self
823    }
824
825    fn as_any_mut(&mut self) -> &mut dyn Any {
826        self
827    }
828
829    fn eq(&self, other: &dyn ProtocolSim) -> bool {
830        other.as_any().downcast_ref::<Self>() == Some(self)
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    /// The attributes the pool component carries, in the order the decoder reads them.
837    pub(super) const POOL_ATTRS: [&str; 18] = [
838        TOTAL_VALUE_OUT_OF_LP_ATTR,
839        TOTAL_VALUE_IN_LP_ATTR,
840        TOTAL_SHARES_ATTR,
841        REDEMPTION_BUCKET.capacity,
842        REDEMPTION_BUCKET.remaining,
843        REDEMPTION_BUCKET.last_refill,
844        REDEMPTION_BUCKET.refill_rate,
845        EXIT_FEE_SPLIT_TO_TREASURY_BPS_ATTR,
846        EXIT_FEE_BPS_ATTR,
847        LOW_WATERMARK_BPS_ATTR,
848        MINT_BUCKET.capacity,
849        MINT_BUCKET.remaining,
850        MINT_BUCKET.last_refill,
851        MINT_BUCKET.refill_rate,
852        BURN_BUCKET.capacity,
853        BURN_BUCKET.remaining,
854        BURN_BUCKET.last_refill,
855        BURN_BUCKET.refill_rate,
856    ];
857
858    /// The attributes the wrapper component carries.
859    pub(super) const WRAPPER_ATTRS: [&str; 4] =
860        [TOTAL_VALUE_OUT_OF_LP_ATTR, TOTAL_VALUE_IN_LP_ATTR, TOTAL_SHARES_ATTR, WEETH_SHARES_ATTR];
861    use std::collections::HashMap;
862
863    use tycho_client::feed::BlockHeader;
864    use tycho_common::{
865        dto::ProtocolStateDelta,
866        models::{
867            protocol::{ProtocolComponent, ProtocolComponentState},
868            Chain,
869        },
870        simulation::errors::{SimulationError, TransitionError},
871        Bytes,
872    };
873
874    use super::*;
875    use crate::{
876        evm::protocol::test_utils::try_decode_snapshot_with_defaults,
877        protocol::{errors::InvalidSnapshotError, models::TryFromWithBlock},
878    };
879
880    /// Block 25940000, where the package starts.
881    const BLOCK_TIMESTAMP: u64 = 1_788_959_135;
882
883    fn u256_dec(value: &str) -> U256 {
884        U256::from_str_radix(value, 10).expect("valid base-10 U256")
885    }
886
887    fn token(address: [u8; 20], symbol: &str) -> Token {
888        Token::new(&Bytes::from(address), symbol, 18, 0, &[Some(0)], Chain::Ethereum, 100)
889    }
890
891    fn eeth_token() -> Token {
892        token(EETH_ADDRESS, "eETH")
893    }
894
895    fn weeth_token() -> Token {
896        token(WEETH_ADDRESS, "weETH")
897    }
898
899    fn eth_token() -> Token {
900        token(ETH_ADDRESS, "ETH")
901    }
902
903    fn one_eth() -> U256 {
904        U256::from(10u64).pow(U256::from(18u64))
905    }
906
907    /// `EtherFiRedemptionManager.tokenToRedemptionInfo(ETH)` at block 25940000.
908    fn redemption_info() -> RedemptionInfo {
909        RedemptionInfo {
910            limit: BucketLimit {
911                capacity: 2_000_000_000,
912                remaining: 1_999_666_518,
913                last_refill: 1_787_040_551,
914                refill_rate: 23_148,
915            },
916            exit_fee_split_to_treasury_bps: 1000,
917            exit_fee_bps: 30,
918            low_watermark_bps: 100,
919        }
920    }
921
922    /// `EtherFiRateLimiter.getLimit` for the two eETH buckets at block 25940000.
923    fn pool_venue() -> PoolState {
924        PoolState {
925            redemption: redemption_info(),
926            mint_limit: BucketLimit {
927                capacity: 40_000_000_000_000,
928                remaining: 39_997_892_494_750,
929                last_refill: 1_788_957_011,
930                refill_rate: 22_222_222_222,
931            },
932            burn_limit: BucketLimit {
933                capacity: 25_000_000_000_000,
934                remaining: 24_975_002_499_999,
935                last_refill: 1_788_923_411,
936                refill_rate: 1_736_111_111,
937            },
938        }
939    }
940
941    /// The pool component at block 25940000: 1051 ETH liquid against a 22079 ETH floor, so
942    /// redemption is closed.
943    fn pool_state() -> EtherfiState {
944        EtherfiState::new(
945            BLOCK_TIMESTAMP,
946            u256_dec("2206910247995761361317226"),
947            u256_dec("1051493289032982041238"),
948            u256_dec("2001243491556134113932753"),
949            Venue::Pool(pool_venue()),
950        )
951    }
952
953    /// The pool component with 30,000 ETH liquid: ~7,600 ETH above the floor.
954    fn pool_state_with_liquidity() -> EtherfiState {
955        let mut state = pool_state();
956        state.total_value_in_lp = U256::from(30_000u64) * one_eth();
957        state
958    }
959
960    /// The wrapper component at block 25940000.
961    fn wrapper_state() -> EtherfiState {
962        EtherfiState::new(
963            BLOCK_TIMESTAMP,
964            u256_dec("2206910247995761361317226"),
965            u256_dec("1051493289032982041238"),
966            u256_dec("2001243491556134113932753"),
967            Venue::Wrapper(WrapperState { weeth_shares: u256_dec("1934528716353929340955601") }),
968        )
969    }
970
971    /// The names the component carries.
972    fn attribute_names(state: &EtherfiState) -> &'static [&'static str] {
973        match &state.venue {
974            Venue::Pool(_) => &POOL_ATTRS,
975            Venue::Wrapper(_) => &WRAPPER_ATTRS,
976        }
977    }
978
979    fn pool_of(state: &EtherfiState) -> PoolState {
980        let Venue::Pool(pool) = &state.venue else { panic!("not the pool component") };
981        *pool
982    }
983
984    fn wrapper_of(state: &EtherfiState) -> WrapperState {
985        let Venue::Wrapper(wrapper) = &state.venue else { panic!("not the wrapper component") };
986        *wrapper
987    }
988
989    fn state_of(result: &GetAmountOutResult) -> EtherfiState {
990        result
991            .new_state
992            .as_any()
993            .downcast_ref::<EtherfiState>()
994            .expect("etherfi state")
995            .clone()
996    }
997
998    fn recoverable(err: SimulationError) -> String {
999        let SimulationError::RecoverableError(message) = err else {
1000            panic!("expected a recoverable error, got {err:?}");
1001        };
1002        message
1003    }
1004
1005    /// `eETH.balanceOf(weETH)` at block 25940000, to the wei: the wrapper's shares at the rate.
1006    #[test]
1007    fn wrapper_shares_are_worth_the_chain_balance() {
1008        let state = wrapper_state();
1009        let worth = state
1010            .amount_for_share(wrapper_of(&state).weeth_shares)
1011            .expect("amount");
1012        assert_eq!(worth, u256_dec("2134355669936453442791966"));
1013    }
1014
1015    #[test]
1016    fn deposit_credits_an_eeth_balance_near_the_deposit() {
1017        let state = pool_state();
1018
1019        let result = state
1020            .get_amount_out(u256_to_biguint(one_eth()), &eth_token(), &eeth_token())
1021            .expect("amount out");
1022
1023        // Depositing 1 ETH credits an eETH balance worth ~1 ETH, above the share count.
1024        let shares = state
1025            .shares_for_amount(one_eth())
1026            .expect("shares");
1027        assert!(result.amount > u256_to_biguint(shares));
1028        let deposit = u256_to_biguint(one_eth());
1029        assert!(&deposit - &result.amount < u256_to_biguint(one_eth() / U256::from(1_000_000u32)));
1030
1031        let next = state_of(&result);
1032        assert_eq!(next.total_value_in_lp, state.total_value_in_lp + one_eth());
1033        assert_eq!(next.total_shares, state.total_shares + shares);
1034    }
1035
1036    #[test]
1037    fn deposit_draws_the_minted_balance_from_the_mint_bucket() {
1038        let state = pool_state();
1039        let before = pool_of(&state)
1040            .mint_limit
1041            .refilled(BLOCK_TIMESTAMP);
1042
1043        let result = state
1044            .get_amount_out(u256_to_biguint(one_eth()), &eth_token(), &eeth_token())
1045            .expect("amount out");
1046
1047        let after = pool_of(&state_of(&result)).mint_limit;
1048        let drawn = gwei_units(biguint_to_u256(&result.amount));
1049        assert_eq!(after.remaining, before.remaining - drawn);
1050        assert_eq!(after.last_refill, BLOCK_TIMESTAMP);
1051    }
1052
1053    /// The bucket refilled to its 40,000 ETH capacity by block 25940000, so that is the cap.
1054    #[test]
1055    fn deposit_limit_is_the_mint_bucket() {
1056        let state = pool_state();
1057
1058        let (max_in, max_out) = state
1059            .get_limits(Bytes::from(ETH_ADDRESS), Bytes::from(EETH_ADDRESS))
1060            .expect("limits");
1061
1062        assert_eq!(max_in, u256_to_biguint(U256::from(40_000u64) * one_eth()));
1063        assert!(max_out > &max_in * 999u32 / 1000u32);
1064        // The limit settles, and one bucket unit more does not.
1065        let quoted = state
1066            .get_amount_out(max_in.clone(), &eth_token(), &eeth_token())
1067            .expect("a quote at the limit");
1068        assert_eq!(quoted.amount, max_out);
1069        let err = state
1070            .get_amount_out(&max_in + BigUint::from(GWEI), &eth_token(), &eeth_token())
1071            .unwrap_err();
1072        assert_eq!(recoverable(err), "MINT_RATE_LIMIT");
1073    }
1074
1075    /// `totalValueInLp` is a `uint128`; a deposit that overflows it reverts on chain.
1076    #[test]
1077    fn deposit_limit_respects_the_uint128_pool_value() {
1078        let mut state = pool_state();
1079        let headroom = U256::from(5u64) * U256::from(GWEI);
1080        state.total_value_in_lp = U256::from(u128::MAX) - headroom;
1081        // Keep the share rate at parity, so a deposit inside the headroom still mints shares.
1082        state.total_shares = state
1083            .total_pooled_ether()
1084            .expect("pooled");
1085
1086        let (max_in, _) = state
1087            .get_limits(Bytes::from(ETH_ADDRESS), Bytes::from(EETH_ADDRESS))
1088            .expect("limits");
1089
1090        assert_eq!(max_in, u256_to_biguint(headroom));
1091        let err = state
1092            .get_amount_out(&max_in + BigUint::from(1u8), &eth_token(), &eeth_token())
1093            .unwrap_err();
1094        assert_eq!(recoverable(err), "LIQUIDITY_POOL_CAPACITY");
1095    }
1096
1097    #[test]
1098    fn redemption_is_closed_below_the_low_watermark() {
1099        let state = pool_state();
1100
1101        let (max_in, max_out) = state
1102            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
1103            .expect("limits");
1104
1105        assert_eq!(max_in, BigUint::ZERO);
1106        assert_eq!(max_out, BigUint::ZERO);
1107        let err = state
1108            .get_amount_out(BigUint::from(1u64), &eeth_token(), &eth_token())
1109            .unwrap_err();
1110        assert_eq!(recoverable(err), "EXCEEDED_REDEEMABLE");
1111    }
1112
1113    /// `_calcRedemption`: the receiver gets the shares net of the 30 bps fee, the treasury keeps
1114    /// a tenth of the fee shares, and the rest of the fee shares are burnt for the stakers.
1115    #[test]
1116    fn redemption_pays_net_of_the_exit_fee_and_burns_the_stakers_share() {
1117        let state = pool_state_with_liquidity();
1118
1119        let result = state
1120            .get_amount_out(u256_to_biguint(one_eth()), &eeth_token(), &eth_token())
1121            .expect("amount out");
1122
1123        // 30 bps off one eETH, and the payout is worth 891962367676204779 shares.
1124        assert_eq!(result.amount, BigUint::from(996_999_999_999_999_999u64));
1125
1126        let next = state_of(&result);
1127        // The redemption is paid out of `totalValueInLp`.
1128        assert_eq!(next.total_value_in_lp, u256_dec("29999003000000000000001"));
1129        // `sharesForWithdrawalAmount` rounds up, so the pool keeps the odd wei-share. What
1130        // leaves `totalShares` is the payout's shares plus the stakers' nine tenths of the
1131        // 2683938919787979 fee shares; the treasury keeps the other tenth as eETH.
1132        assert_eq!(state.total_shares - next.total_shares, u256_dec("894377912704013961"));
1133        assert_eq!(next.total_shares, u256_dec("2001242597178221409918792"));
1134
1135        // Both burns are metered in gwei, rounded up, against the eETH burn bucket, which has
1136        // refilled to its 25,000 ETH capacity by this block: 997000000 units for the payout and
1137        // 2700001 for the stakers' fee.
1138        let Venue::Pool(pool) = &next.venue else {
1139            panic!("the pool component stays a pool");
1140        };
1141        assert_eq!(pool.burn_limit.remaining, 24_999_000_299_999);
1142    }
1143
1144    #[test]
1145    fn redemption_burn_metering_uses_each_post_burn_share_rate() {
1146        let state = pool_state_with_liquidity();
1147        let result = state
1148            .get_amount_out(
1149                u256_to_biguint(one_eth() * U256::from(1000)),
1150                &eeth_token(),
1151                &eth_token(),
1152            )
1153            .expect("redemption");
1154        let next = state_of(&result);
1155        let Venue::Pool(pool) = next.venue else { panic!("pool") };
1156        // Withdrawal reduces pooled ether, then each burn reduces
1157        // totalShares before valuing its shares for the burn bucket. The two burns consume
1158        // 997000000000 and 2700003261 gwei units respectively.
1159        assert_eq!(pool.burn_limit.remaining, 24_000_299_996_739);
1160    }
1161
1162    #[test]
1163    fn redemption_rejects_burn_capacity_below_post_burn_charge() {
1164        let mut state = pool_state_with_liquidity();
1165        let Venue::Pool(ref mut pool) = state.venue else { panic!("pool") };
1166        pool.burn_limit.remaining = 999_700_000_001;
1167        pool.burn_limit.last_refill = BLOCK_TIMESTAMP;
1168        // The available burn capacity is 3260 units below the required on-chain charge.
1169        let err = state
1170            .get_amount_out(
1171                u256_to_biguint(one_eth() * U256::from(1000)),
1172                &eeth_token(),
1173                &eth_token(),
1174            )
1175            .unwrap_err();
1176        assert_eq!(recoverable(err), "BURN_RATE_LIMIT");
1177    }
1178
1179    #[test]
1180    fn redemption_limit_remains_executable_when_all_fees_go_to_stakers() {
1181        let mut state = pool_state_with_liquidity();
1182        let Venue::Pool(ref mut pool) = state.venue else { panic!("pool") };
1183        pool.redemption
1184            .exit_fee_split_to_treasury_bps = 0;
1185        pool.burn_limit.capacity = 1_000_000_000_000;
1186        pool.burn_limit.remaining = 1_000_000_000_000;
1187        pool.burn_limit.last_refill = BLOCK_TIMESTAMP;
1188        let (limit, output) = state
1189            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
1190            .expect("executable limit");
1191        assert!(limit > u256_to_biguint(one_eth() * U256::from(999)));
1192        assert!(limit < u256_to_biguint(one_eth() * U256::from(1000)));
1193        let quote = state
1194            .get_amount_out(limit.clone(), &eeth_token(), &eth_token())
1195            .expect("limit quotes");
1196        assert_eq!(quote.amount, output);
1197    }
1198
1199    #[test]
1200    fn conservative_redemption_limits_settle_across_fee_and_burn_budgets() {
1201        for fee in [0, 30, 5000, 9999, 10000] {
1202            for treasury_split in [0, 1000, 10000] {
1203                for burn_units in [0, 1, 2, 100, 1_000_000_000_000] {
1204                    let mut state = pool_state_with_liquidity();
1205                    // A large burn budget relative to pooled ether exercises substantial
1206                    // share-rate changes during redemption.
1207                    state.total_value_in_lp = one_eth() * U256::from(100);
1208                    state.total_value_out_of_lp = U256::ZERO;
1209                    state.total_shares = one_eth() * U256::from(80);
1210                    let Venue::Pool(ref mut pool) = state.venue else { panic!("pool") };
1211                    pool.redemption.exit_fee_bps = fee;
1212                    pool.redemption
1213                        .exit_fee_split_to_treasury_bps = treasury_split;
1214                    pool.redemption.low_watermark_bps = 0;
1215                    pool.burn_limit.capacity = burn_units;
1216                    pool.burn_limit.remaining = burn_units;
1217                    pool.burn_limit.last_refill = BLOCK_TIMESTAMP;
1218                    let (limit, output) = state
1219                        .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
1220                        .expect("conservative limit");
1221                    if burn_units <= 1 || fee == 10000 {
1222                        assert_eq!((limit, output), (BigUint::ZERO, BigUint::ZERO));
1223                        continue;
1224                    }
1225                    assert!(limit > BigUint::ZERO);
1226                    let quote = state
1227                        .get_amount_out(limit, &eeth_token(), &eth_token())
1228                        .expect("limit settles within both burn charges");
1229                    assert_eq!(quote.amount, output);
1230                }
1231            }
1232        }
1233    }
1234
1235    /// Both components report zero. The wrapper's is its real fee; the pool's understates the
1236    /// exit fee it charges one way, which this interface cannot express and `spot_price` and
1237    /// `get_amount_out` carry instead.
1238    #[test]
1239    fn fee_is_zero_until_the_interface_can_carry_a_direction() {
1240        assert_eq!(wrapper_state().fee(), 0.0);
1241        assert_eq!(pool_state().fee(), 0.0);
1242
1243        // The exit fee is in the quote, which is where a caller has to read it.
1244        let quoted = pool_state_with_liquidity()
1245            .get_amount_out(u256_to_biguint(one_eth()), &eeth_token(), &eth_token())
1246            .expect("amount out");
1247        assert!(biguint_to_u256(&quoted.amount) < one_eth());
1248    }
1249
1250    /// The redemption manager rejects an exit fee above the basis-point scale, so one that
1251    /// arrives anyway is malformed. Subtracting it would wrap in a release build and quote a
1252    /// payout many times the input.
1253    #[test]
1254    fn an_exit_fee_above_the_basis_point_scale_is_refused() {
1255        let mut state = pool_state_with_liquidity();
1256        if let Venue::Pool(pool) = &mut state.venue {
1257            pool.redemption.exit_fee_bps = 10_001;
1258        }
1259
1260        let err = state
1261            .get_amount_out(u256_to_biguint(one_eth()), &eeth_token(), &eth_token())
1262            .unwrap_err();
1263        assert!(matches!(err, SimulationError::FatalError(_)), "{err:?}");
1264        assert!(state
1265            .spot_price(&eeth_token(), &eth_token())
1266            .is_err());
1267    }
1268
1269    /// 7,600 ETH above the floor, but the redemption bucket holds 2,000 ETH.
1270    #[test]
1271    fn redemption_limit_is_bounded_by_the_redemption_bucket() {
1272        let state = pool_state_with_liquidity();
1273
1274        let (max_in, max_out) = state
1275            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
1276            .expect("limits");
1277
1278        assert_eq!(max_in, u256_to_biguint(U256::from(2_000u64) * one_eth()));
1279        let quoted = state
1280            .get_amount_out(max_in.clone(), &eeth_token(), &eth_token())
1281            .expect("a quote at the limit");
1282        assert_eq!(quoted.amount, max_out);
1283        let err = state
1284            .get_amount_out(
1285                &max_in + BigUint::from(REDEMPTION_BUCKET_UNIT),
1286                &eeth_token(),
1287                &eth_token(),
1288            )
1289            .unwrap_err();
1290        assert_eq!(recoverable(err), "REDEMPTION_RATE_LIMIT");
1291    }
1292
1293    #[test]
1294    fn redemption_limit_is_bounded_by_the_liquidity_above_the_floor() {
1295        let mut state = pool_state();
1296        // The floor is 1% of getTotalPooledEther(). These two put it at 1,000 ETH and leave
1297        // exactly 10 ETH above it, which is far under both rate-limit buckets.
1298        state.total_value_in_lp = U256::from(1_010u64) * one_eth();
1299        state.total_value_out_of_lp = U256::from(98_990u64) * one_eth();
1300
1301        let (max_in, _) = state
1302            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
1303            .expect("limits");
1304
1305        assert_eq!(max_in, u256_to_biguint(U256::from(10u64) * one_eth()));
1306        state
1307            .get_amount_out(max_in, &eeth_token(), &eth_token())
1308            .expect("a quote at the reported limit");
1309    }
1310
1311    #[test]
1312    fn redemption_bucket_refills_with_the_execution_block() {
1313        let state = pool_state_with_liquidity();
1314        let (max_in, _) = state
1315            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
1316            .expect("limits");
1317        let mut drained = state_of(
1318            &state
1319                .get_amount_out(max_in, &eeth_token(), &eth_token())
1320                .expect("amount out"),
1321        );
1322
1323        let err = drained
1324            .get_amount_out(u256_to_biguint(one_eth()), &eeth_token(), &eth_token())
1325            .unwrap_err();
1326        assert_eq!(recoverable(err), "REDEMPTION_RATE_LIMIT");
1327
1328        // 1000 seconds refill 1000 * 23148 units = 23.148 ETH.
1329        assert!(drained.apply_block(&BlockContext::new(25_940_100, BLOCK_TIMESTAMP + 1000)));
1330        let (max_in, _) = drained
1331            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS))
1332            .expect("limits");
1333        assert_eq!(
1334            max_in,
1335            u256_to_biguint(U256::from(23_148_000u64) * U256::from(REDEMPTION_BUCKET_UNIT))
1336        );
1337        drained
1338            .get_amount_out(
1339                u256_to_biguint(U256::from(20u64) * one_eth()),
1340                &eeth_token(),
1341                &eth_token(),
1342            )
1343            .expect("a quote after refilling");
1344    }
1345
1346    #[test]
1347    fn apply_block_is_idempotent_and_only_reports_a_changed_capacity() {
1348        let mut pool = pool_state();
1349        assert!(!pool.apply_block(&BlockContext::new(25_940_000, BLOCK_TIMESTAMP)));
1350        // Every bucket is full at this block, so time alone changes nothing.
1351        assert!(!pool.apply_block(&BlockContext::new(25_940_001, BLOCK_TIMESTAMP + 12)));
1352        assert_eq!(pool.execution_block_timestamp, BLOCK_TIMESTAMP + 12);
1353
1354        let mut wrapper = wrapper_state();
1355        assert!(!wrapper.apply_block(&BlockContext::new(25_940_001, BLOCK_TIMESTAMP + 12)));
1356    }
1357
1358    #[test]
1359    fn wrapping_and_unwrapping_move_the_wrapper_shares() {
1360        let state = wrapper_state();
1361        let shares_before = wrapper_of(&state).weeth_shares;
1362
1363        let wrapped = state
1364            .get_amount_out(u256_to_biguint(one_eth()), &eeth_token(), &weeth_token())
1365            .expect("wrap");
1366        let weeth = biguint_to_u256(&wrapped.amount);
1367        assert_eq!(
1368            weeth,
1369            state
1370                .shares_for_amount(one_eth())
1371                .expect("shares")
1372        );
1373        let after_wrap = state_of(&wrapped);
1374        assert_eq!(wrapper_of(&after_wrap).weeth_shares, shares_before + weeth);
1375
1376        let unwrapped = after_wrap
1377            .get_amount_out(wrapped.amount.clone(), &weeth_token(), &eeth_token())
1378            .expect("unwrap");
1379        let eeth = biguint_to_u256(&unwrapped.amount);
1380        assert_eq!(
1381            eeth,
1382            state
1383                .amount_for_share(weeth)
1384                .expect("amount")
1385        );
1386        // The transfer moves the shares the payout is worth, which rounding can leave a share
1387        // short of the weETH burnt.
1388        let moved = state
1389            .shares_for_amount(eeth)
1390            .expect("shares");
1391        assert!(moved <= weeth && weeth - moved <= U256::ONE);
1392        assert_eq!(wrapper_of(&state_of(&unwrapped)).weeth_shares, shares_before + weeth - moved);
1393    }
1394
1395    #[test]
1396    fn unwrap_is_bounded_by_the_wrapper_shares() {
1397        let state = wrapper_state();
1398        let shares = wrapper_of(&state).weeth_shares;
1399
1400        let (max_in, max_out) = state
1401            .get_limits(Bytes::from(WEETH_ADDRESS), Bytes::from(EETH_ADDRESS))
1402            .expect("limits");
1403
1404        assert_eq!(max_in, u256_to_biguint(shares));
1405        assert_eq!(max_out, u256_to_biguint(u256_dec("2134355669936453442791966")));
1406        let quoted = state
1407            .get_amount_out(max_in.clone(), &weeth_token(), &eeth_token())
1408            .expect("a quote at the limit");
1409        assert_eq!(quoted.amount, max_out);
1410        let err = state
1411            .get_amount_out(&max_in + BigUint::from(1u8), &weeth_token(), &eeth_token())
1412            .unwrap_err();
1413        assert_eq!(recoverable(err), "WRAPPER_BALANCE_EXCEEDED");
1414    }
1415
1416    #[test]
1417    fn unwrapping_everything_closes_the_direction() {
1418        let state = wrapper_state();
1419        let (max_in, _) = state
1420            .get_limits(Bytes::from(WEETH_ADDRESS), Bytes::from(EETH_ADDRESS))
1421            .expect("limits");
1422        let emptied = state_of(
1423            &state
1424                .get_amount_out(max_in, &weeth_token(), &eeth_token())
1425                .expect("unwrap"),
1426        );
1427
1428        let (max_in, max_out) = emptied
1429            .get_limits(Bytes::from(WEETH_ADDRESS), Bytes::from(EETH_ADDRESS))
1430            .expect("limits");
1431        // Rounding may leave a share behind; at most that.
1432        assert!(max_in <= BigUint::from(1u8));
1433        assert!(max_out <= BigUint::from(2u8));
1434    }
1435
1436    #[test]
1437    fn wrap_limit_is_the_eeth_outside_the_wrapper() {
1438        let state = wrapper_state();
1439
1440        let (max_in, max_out) = state
1441            .get_limits(Bytes::from(EETH_ADDRESS), Bytes::from(WEETH_ADDRESS))
1442            .expect("limits");
1443
1444        let outside = state
1445            .total_pooled_ether()
1446            .expect("pooled") -
1447            u256_dec("2134355669936453442791966");
1448        assert_eq!(max_in, u256_to_biguint(outside));
1449        assert_eq!(
1450            max_out,
1451            u256_to_biguint(
1452                state
1453                    .shares_for_amount(outside)
1454                    .expect("shares")
1455            )
1456        );
1457        let quoted = state
1458            .get_amount_out(max_in, &eeth_token(), &weeth_token())
1459            .expect("a quote at the limit");
1460        assert_eq!(quoted.amount, max_out);
1461    }
1462
1463    #[test]
1464    fn zero_amount_is_refused_in_every_direction() {
1465        let pool = pool_state_with_liquidity();
1466        let wrapper = wrapper_state();
1467        for (state, token_in, token_out) in [
1468            (&pool, eth_token(), eeth_token()),
1469            (&pool, eeth_token(), eth_token()),
1470            (&wrapper, eeth_token(), weeth_token()),
1471            (&wrapper, weeth_token(), eeth_token()),
1472        ] {
1473            let err = state
1474                .get_amount_out(BigUint::ZERO, &token_in, &token_out)
1475                .unwrap_err();
1476            assert_eq!(recoverable(err), "ZERO_AMOUNT");
1477        }
1478    }
1479
1480    #[test]
1481    fn spot_prices_cover_each_components_directions() {
1482        let pool = pool_state();
1483        let deposit = pool
1484            .spot_price(&eth_token(), &eeth_token())
1485            .expect("price");
1486        let redeem = pool
1487            .spot_price(&eeth_token(), &eth_token())
1488            .expect("price");
1489        // Both legs are ~1:1; only the 30 bps exit fee moves the redeem leg off parity.
1490        assert!((deposit - 1.0).abs() < 1e-6, "deposit off parity: {deposit}");
1491        assert!(redeem < 1.0 && redeem > 0.996, "redeem not fee-adjusted: {redeem}");
1492
1493        let wrapper = wrapper_state();
1494        let wrap = wrapper
1495            .spot_price(&eeth_token(), &weeth_token())
1496            .expect("price");
1497        let unwrap = wrapper
1498            .spot_price(&weeth_token(), &eeth_token())
1499            .expect("price");
1500        // eETH.balanceOf(weETH) / eETH.shares(weETH) at block 25940000 is ~1.1033.
1501        assert!((unwrap - 1.1033).abs() < 1e-3, "unwrap rate: {unwrap}");
1502        assert!((wrap * unwrap - 1.0).abs() < 1e-6, "wrap and unwrap are not inverse");
1503    }
1504
1505    /// A pair the component does not hold is a wiring error, not a direction with no capacity.
1506    #[test]
1507    fn a_pair_the_component_does_not_hold_is_an_error() {
1508        let pool = pool_state();
1509        let wrapper = wrapper_state();
1510        for (state, sell, buy) in [
1511            (&pool, WEETH_ADDRESS, EETH_ADDRESS),
1512            (&wrapper, ETH_ADDRESS, EETH_ADDRESS),
1513            (&pool, WEETH_ADDRESS, ETH_ADDRESS),
1514            (&wrapper, WEETH_ADDRESS, ETH_ADDRESS),
1515        ] {
1516            assert!(matches_fatal(state.get_limits(Bytes::from(sell), Bytes::from(buy))));
1517            assert!(matches_fatal(state.get_amount_out(
1518                BigUint::from(1u64),
1519                &token(sell, "in"),
1520                &token(buy, "out")
1521            )));
1522            assert!(matches_fatal(state.spot_price(&token(sell, "in"), &token(buy, "out"))));
1523        }
1524    }
1525
1526    fn matches_fatal<T>(result: Result<T, SimulationError>) -> bool {
1527        match result {
1528            Err(SimulationError::FatalError(_)) => true,
1529            Err(_) | Ok(_) => false,
1530        }
1531    }
1532
1533    /// Encodes an attribute the way the substreams package does: big-endian with no leading zero
1534    /// bytes, and a single zero byte for zero.
1535    fn attribute(value: U256) -> Bytes {
1536        let bytes = value.to_be_bytes_vec();
1537        let start = bytes
1538            .iter()
1539            .position(|byte| *byte != 0)
1540            .unwrap_or(bytes.len() - 1);
1541        Bytes::from(bytes[start..].to_vec())
1542    }
1543
1544    fn bucket_attributes(bucket: &BucketLimit, names: &BucketAttributes) -> Vec<(String, Bytes)> {
1545        vec![
1546            (names.capacity.to_string(), attribute(U256::from(bucket.capacity))),
1547            (names.remaining.to_string(), attribute(U256::from(bucket.remaining))),
1548            (names.last_refill.to_string(), attribute(U256::from(bucket.last_refill))),
1549            (names.refill_rate.to_string(), attribute(U256::from(bucket.refill_rate))),
1550        ]
1551    }
1552
1553    fn common_attributes(state: &EtherfiState) -> Vec<(String, Bytes)> {
1554        vec![
1555            (TOTAL_VALUE_OUT_OF_LP_ATTR.to_string(), attribute(state.total_value_out_of_lp)),
1556            (TOTAL_VALUE_IN_LP_ATTR.to_string(), attribute(state.total_value_in_lp)),
1557            (TOTAL_SHARES_ATTR.to_string(), attribute(state.total_shares)),
1558        ]
1559    }
1560
1561    fn pool_attributes(state: &EtherfiState) -> HashMap<String, Bytes> {
1562        let pool = pool_of(state);
1563        let mut attributes = common_attributes(state);
1564        attributes.extend(bucket_attributes(&pool.redemption.limit, &REDEMPTION_BUCKET));
1565        attributes.extend(bucket_attributes(&pool.mint_limit, &MINT_BUCKET));
1566        attributes.extend(bucket_attributes(&pool.burn_limit, &BURN_BUCKET));
1567        attributes.extend([
1568            (
1569                EXIT_FEE_SPLIT_TO_TREASURY_BPS_ATTR.to_string(),
1570                attribute(U256::from(
1571                    pool.redemption
1572                        .exit_fee_split_to_treasury_bps,
1573                )),
1574            ),
1575            (EXIT_FEE_BPS_ATTR.to_string(), attribute(U256::from(pool.redemption.exit_fee_bps))),
1576            (
1577                LOW_WATERMARK_BPS_ATTR.to_string(),
1578                attribute(U256::from(pool.redemption.low_watermark_bps)),
1579            ),
1580        ]);
1581        attributes.into_iter().collect()
1582    }
1583
1584    fn wrapper_attributes(state: &EtherfiState) -> HashMap<String, Bytes> {
1585        let mut attributes = common_attributes(state);
1586        attributes.push((WEETH_SHARES_ATTR.to_string(), attribute(wrapper_of(state).weeth_shares)));
1587        attributes.into_iter().collect()
1588    }
1589
1590    fn snapshot(
1591        component_id: &str,
1592        attributes: HashMap<String, Bytes>,
1593    ) -> tycho_client::feed::synchronizer::ComponentWithState {
1594        tycho_client::feed::synchronizer::ComponentWithState {
1595            state: ProtocolComponentState {
1596                component_id: component_id.to_string(),
1597                attributes,
1598                balances: HashMap::new(),
1599            },
1600            component: ProtocolComponent {
1601                id: component_id.to_string(),
1602                protocol_system: "etherfi".to_string(),
1603                protocol_type_name: "ethereum_etherfi_pool".to_string(),
1604                chain: Chain::Ethereum,
1605                tokens: Vec::new(),
1606                contract_addresses: Vec::new(),
1607                static_attributes: HashMap::new(),
1608                change: Default::default(),
1609                creation_tx: Bytes::new(),
1610                created_at: chrono::DateTime::UNIX_EPOCH.naive_utc(),
1611            },
1612            component_tvl: None,
1613            entrypoints: Vec::new(),
1614        }
1615    }
1616
1617    async fn decode(
1618        snapshot: tycho_client::feed::synchronizer::ComponentWithState,
1619    ) -> Result<EtherfiState, InvalidSnapshotError> {
1620        EtherfiState::try_from_with_header(
1621            snapshot,
1622            BlockHeader { timestamp: BLOCK_TIMESTAMP, ..Default::default() },
1623            &HashMap::default(),
1624            &HashMap::default(),
1625            &Default::default(),
1626        )
1627        .await
1628    }
1629
1630    #[tokio::test]
1631    async fn decoder_builds_the_pool_component() {
1632        let expected = pool_state();
1633        let decoded = decode(snapshot(POOL_COMPONENT_ID, pool_attributes(&expected)))
1634            .await
1635            .expect("decoded");
1636        assert_eq!(decoded, expected);
1637    }
1638
1639    #[tokio::test]
1640    async fn decoder_builds_the_wrapper_component() {
1641        let expected = wrapper_state();
1642        let decoded = decode(snapshot(WRAPPER_COMPONENT_ID, wrapper_attributes(&expected)))
1643            .await
1644            .expect("decoded");
1645        assert_eq!(decoded, expected);
1646    }
1647
1648    #[tokio::test]
1649    async fn decoder_accepts_a_checksummed_component_id() {
1650        let expected = wrapper_state();
1651        let decoded = decode(snapshot(
1652            "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee",
1653            wrapper_attributes(&expected),
1654        ))
1655        .await
1656        .expect("decoded");
1657        assert_eq!(decoded, expected);
1658    }
1659
1660    #[tokio::test]
1661    async fn decoder_rejects_an_unknown_component_id() {
1662        let err = try_decode_snapshot_with_defaults::<EtherfiState>(snapshot(
1663            "0xdeadbeef",
1664            wrapper_attributes(&wrapper_state()),
1665        ))
1666        .await
1667        .unwrap_err();
1668        let InvalidSnapshotError::ValueError(message) = err else {
1669            panic!("expected a value error, got {err:?}");
1670        };
1671        assert!(message.contains("0xdeadbeef"), "{message}");
1672    }
1673
1674    #[tokio::test]
1675    async fn decoder_rejects_a_missing_attribute() {
1676        let mut attributes = pool_attributes(&pool_state());
1677        attributes.remove(MINT_BUCKET.remaining);
1678        let err = decode(snapshot(POOL_COMPONENT_ID, attributes))
1679            .await
1680            .unwrap_err();
1681        let InvalidSnapshotError::MissingAttribute(name) = err else {
1682            panic!("expected a missing attribute, got {err:?}");
1683        };
1684        assert_eq!(name, MINT_BUCKET.remaining);
1685    }
1686
1687    /// A whole word where the package emits a `uint128` half cannot be a value the package
1688    /// produced, so the decoder reports it by name.
1689    #[tokio::test]
1690    async fn decoder_rejects_an_attribute_wider_than_its_field() {
1691        let state = pool_state();
1692        let mut attributes = pool_attributes(&state);
1693        attributes.insert(
1694            TOTAL_VALUE_IN_LP_ATTR.to_string(),
1695            Bytes::from(
1696                state
1697                    .total_value_in_lp
1698                    .to_be_bytes_vec(),
1699            ),
1700        );
1701        let err = decode(snapshot(POOL_COMPONENT_ID, attributes))
1702            .await
1703            .unwrap_err();
1704        let InvalidSnapshotError::ValueError(message) = err else {
1705            panic!("expected a value error, got {err:?}");
1706        };
1707        assert!(message.contains(TOTAL_VALUE_IN_LP_ATTR), "{message}");
1708    }
1709
1710    fn delta(component_id: &str, attributes: Vec<(String, Bytes)>) -> ProtocolStateDelta {
1711        ProtocolStateDelta {
1712            component_id: component_id.to_string(),
1713            updated_attributes: attributes.into_iter().collect(),
1714            deleted_attributes: Default::default(),
1715        }
1716    }
1717
1718    #[test]
1719    fn delta_transition_updates_the_pool() {
1720        let mut state = pool_state();
1721        let redemption_limit =
1722            BucketLimit { capacity: 5, remaining: 4, last_refill: 3, refill_rate: 2 };
1723        let mut attributes = vec![
1724            (TOTAL_VALUE_IN_LP_ATTR.to_string(), attribute(U256::from(7u64))),
1725            (TOTAL_SHARES_ATTR.to_string(), attribute(U256::from(9u64))),
1726            (EXIT_FEE_BPS_ATTR.to_string(), attribute(U256::from(45u64))),
1727        ];
1728        attributes.extend(bucket_attributes(&redemption_limit, &REDEMPTION_BUCKET));
1729
1730        state
1731            .delta_transition(
1732                delta(POOL_COMPONENT_ID, attributes),
1733                &HashMap::new(),
1734                &Balances::default(),
1735            )
1736            .expect("transition");
1737
1738        assert_eq!(state.total_value_in_lp, U256::from(7u64));
1739        assert_eq!(state.total_shares, U256::from(9u64));
1740        let pool = pool_of(&state);
1741        assert_eq!(pool.redemption.limit, redemption_limit);
1742        assert_eq!(pool.redemption.exit_fee_bps, 45);
1743        assert_eq!(pool.mint_limit, pool_venue().mint_limit);
1744    }
1745
1746    #[test]
1747    fn delta_transition_updates_the_wrapper() {
1748        let mut state = wrapper_state();
1749
1750        state
1751            .delta_transition(
1752                delta(
1753                    WRAPPER_COMPONENT_ID,
1754                    vec![(WEETH_SHARES_ATTR.to_string(), attribute(U256::from(11u64)))],
1755                ),
1756                &HashMap::new(),
1757                &Balances::default(),
1758            )
1759            .expect("transition");
1760
1761        assert_eq!(wrapper_of(&state).weeth_shares, U256::from(11u64));
1762    }
1763
1764    /// A bucket field is 64 bits. A nine-byte value cannot come from the package, and narrowing
1765    /// it would wrap the timestamp the bucket refills from.
1766    #[test]
1767    fn delta_transition_rejects_an_attribute_wider_than_its_field() {
1768        let mut state = pool_state();
1769        let before = state.clone();
1770
1771        let err = state
1772            .delta_transition(
1773                delta(
1774                    POOL_COMPONENT_ID,
1775                    vec![
1776                        (TOTAL_SHARES_ATTR.to_string(), attribute(U256::from(1u64))),
1777                        (MINT_BUCKET.last_refill.to_string(), attribute(U256::from(1u64) << 64)),
1778                    ],
1779                ),
1780                &HashMap::new(),
1781                &Balances::default(),
1782            )
1783            .unwrap_err();
1784
1785        let TransitionError::DecodeError(message) = err else {
1786            panic!("expected a decode error, got {err:?}");
1787        };
1788        assert!(message.contains(MINT_BUCKET.last_refill), "{message}");
1789        assert_eq!(state, before, "a rejected delta must leave the state untouched");
1790    }
1791
1792    #[test]
1793    fn bucket_refill_caps_at_capacity() {
1794        let limit = BucketLimit { capacity: 10, remaining: 1, last_refill: 100, refill_rate: 5 };
1795        let refilled = limit.refilled(103);
1796        assert_eq!(refilled.remaining, 10);
1797        assert_eq!(refilled.last_refill, 103);
1798    }
1799
1800    #[test]
1801    fn bucket_refill_is_a_noop_at_or_before_the_last_refill() {
1802        let limit = BucketLimit { capacity: 10, remaining: 4, last_refill: 100, refill_rate: 5 };
1803        assert_eq!(limit.refilled(100), limit);
1804        assert_eq!(limit.refilled(99), limit);
1805    }
1806
1807    #[test]
1808    fn bucket_consume_draws_after_refilling() {
1809        let limit = BucketLimit { capacity: 10, remaining: 1, last_refill: 100, refill_rate: 2 };
1810        let after = limit
1811            .consume(4, 102)
1812            .expect("consumable");
1813        assert_eq!(after.remaining, 1);
1814        assert_eq!(after.last_refill, 102);
1815        assert!(limit.consume(6, 102).is_none());
1816    }
1817
1818    #[test]
1819    fn gwei_units_round_up_and_saturate() {
1820        assert_eq!(gwei_units(U256::from(GWEI - 1)), 1);
1821        assert_eq!(gwei_units(U256::from(GWEI * 2)), 2);
1822        assert_eq!(gwei_units(U256::MAX), u64::MAX);
1823    }
1824
1825    #[test]
1826    fn redemption_units_round_up_and_reject_oversized_amounts() {
1827        assert_eq!(redemption_units(U256::from(REDEMPTION_BUCKET_UNIT - 1)).unwrap(), 1);
1828        assert_eq!(redemption_units(U256::from(REDEMPTION_BUCKET_UNIT * 3)).unwrap(), 3);
1829        let too_large = U256::from(u64::MAX) * U256::from(REDEMPTION_BUCKET_UNIT);
1830        assert_eq!(recoverable(redemption_units(too_large).unwrap_err()), "AMOUNT_TOO_LARGE");
1831    }
1832
1833    /// The pool with 100 gwei of burn capacity, far under the liquidity above the floor and
1834    /// under the redemption bucket, so the burn bucket is what bounds redemption.
1835    fn pool_state_bound_by_the_burn_bucket() -> EtherfiState {
1836        let mut state = pool_state_with_liquidity();
1837        if let Venue::Pool(pool) = &mut state.venue {
1838            pool.burn_limit = BucketLimit {
1839                capacity: 100,
1840                remaining: 100,
1841                last_refill: BLOCK_TIMESTAMP,
1842                refill_rate: 0,
1843            };
1844        }
1845        state
1846    }
1847
1848    /// The pool with five units of redemption capacity left, so the redemption manager's own
1849    /// bucket is what bounds redemption.
1850    fn pool_state_bound_by_the_redemption_bucket() -> EtherfiState {
1851        let mut state = pool_state_with_liquidity();
1852        if let Venue::Pool(pool) = &mut state.venue {
1853            pool.redemption.limit = BucketLimit {
1854                capacity: 5,
1855                remaining: 5,
1856                last_refill: BLOCK_TIMESTAMP,
1857                refill_rate: 0,
1858            };
1859        }
1860        state
1861    }
1862
1863    /// The pairs a component's venue performs, which are the pairs its three entry points
1864    /// answer for.
1865    fn supported_pairs(state: &EtherfiState) -> Vec<(Bytes, Bytes)> {
1866        match state.venue {
1867            Venue::Pool(_) => vec![
1868                (Bytes::from(ETH_ADDRESS), Bytes::from(EETH_ADDRESS)),
1869                (Bytes::from(EETH_ADDRESS), Bytes::from(ETH_ADDRESS)),
1870            ],
1871            Venue::Wrapper(_) => vec![
1872                (Bytes::from(EETH_ADDRESS), Bytes::from(WEETH_ADDRESS)),
1873                (Bytes::from(WEETH_ADDRESS), Bytes::from(EETH_ADDRESS)),
1874            ],
1875        }
1876    }
1877
1878    fn every_token_pair() -> Vec<(Bytes, Bytes)> {
1879        let tokens = [ETH_ADDRESS, EETH_ADDRESS, WEETH_ADDRESS];
1880        let mut pairs = Vec::new();
1881        for sell in tokens {
1882            for buy in tokens {
1883                if sell != buy {
1884                    pairs.push((Bytes::from(sell), Bytes::from(buy)));
1885                }
1886            }
1887        }
1888        pairs
1889    }
1890
1891    /// A reported limit has to be a trade the venue performs: quoting at it must succeed and
1892    /// return exactly the reported output.
1893    #[test]
1894    fn every_reported_limit_quotes_at_its_own_size() {
1895        for state in [
1896            pool_state_with_liquidity(),
1897            pool_state_bound_by_the_burn_bucket(),
1898            pool_state_bound_by_the_redemption_bucket(),
1899            wrapper_state(),
1900        ] {
1901            for (sell, buy) in supported_pairs(&state) {
1902                let (max_in, max_out) = state
1903                    .get_limits(sell.clone(), buy.clone())
1904                    .unwrap_or_else(|e| panic!("{sell:x} -> {buy:x} has no limit: {e:?}"));
1905                if max_in == BigUint::ZERO {
1906                    assert_eq!(
1907                        max_out,
1908                        BigUint::ZERO,
1909                        "{sell:x} -> {buy:x} pays out of a zero limit"
1910                    );
1911                    continue;
1912                }
1913                let token_in = token(sell.as_ref().try_into().unwrap(), "in");
1914                let token_out = token(buy.as_ref().try_into().unwrap(), "out");
1915                let quoted = state
1916                    .get_amount_out(max_in.clone(), &token_in, &token_out)
1917                    .unwrap_or_else(|e| panic!("{sell:x} -> {buy:x} limit does not quote: {e:?}"));
1918                assert_eq!(
1919                    quoted.amount, max_out,
1920                    "{sell:x} -> {buy:x} limit disagrees with quote"
1921                );
1922            }
1923        }
1924    }
1925
1926    /// A pair the component does not hold is an error in all three entry points, so a token
1927    /// wired to the wrong component cannot read as a direction with no capacity.
1928    #[test]
1929    fn unsupported_pairs_are_refused_by_every_entry_point() {
1930        let supported = [
1931            (POOL_COMPONENT_ID, ETH_ADDRESS, EETH_ADDRESS),
1932            (POOL_COMPONENT_ID, EETH_ADDRESS, ETH_ADDRESS),
1933            (WRAPPER_COMPONENT_ID, EETH_ADDRESS, WEETH_ADDRESS),
1934            (WRAPPER_COMPONENT_ID, WEETH_ADDRESS, EETH_ADDRESS),
1935        ];
1936        for state in [pool_state_with_liquidity(), wrapper_state()] {
1937            let id = match state.venue {
1938                Venue::Pool(_) => POOL_COMPONENT_ID,
1939                Venue::Wrapper(_) => WRAPPER_COMPONENT_ID,
1940            };
1941            for (sell, buy) in every_token_pair() {
1942                let sell_bytes: [u8; 20] = sell.as_ref().try_into().unwrap();
1943                let buy_bytes: [u8; 20] = buy.as_ref().try_into().unwrap();
1944                if supported.contains(&(id, sell_bytes, buy_bytes)) {
1945                    continue;
1946                }
1947                assert!(matches_fatal(state.get_limits(sell.clone(), buy.clone())));
1948                assert!(matches_fatal(state.get_amount_out(
1949                    BigUint::from(1u64),
1950                    &token(sell_bytes, "in"),
1951                    &token(buy_bytes, "out")
1952                )));
1953                assert!(matches_fatal(
1954                    state.spot_price(&token(sell_bytes, "in"), &token(buy_bytes, "out"))
1955                ));
1956            }
1957        }
1958    }
1959
1960    /// `sharesForAmount` and `amountForShare` are inverse up to their rounding, so neither can
1961    /// be applied to a figure already in the other unit without the round trip drifting.
1962    #[test]
1963    fn share_and_amount_round_trip() {
1964        let state = pool_state();
1965        // Each division truncates by under one unit, and the first loss is then scaled by
1966        // the share rate, so a round trip can lose the rate plus one.
1967        let tolerance = state
1968            .amount_for_share(U256::ONE)
1969            .expect("rate") +
1970            U256::from(2u8);
1971        for exponent in [15u32, 18, 21, 24] {
1972            let amount = U256::from(10u64).pow(U256::from(exponent));
1973            let back = state
1974                .amount_for_share(
1975                    state
1976                        .shares_for_amount(amount)
1977                        .expect("shares"),
1978                )
1979                .expect("amount");
1980            assert!(back <= amount && amount - back <= tolerance, "amount drifted at 1e{exponent}");
1981
1982            let shares = U256::from(10u64).pow(U256::from(exponent));
1983            let back = state
1984                .shares_for_amount(
1985                    state
1986                        .amount_for_share(shares)
1987                        .expect("amount"),
1988                )
1989                .expect("shares");
1990            assert!(back <= shares && shares - back <= tolerance, "shares drifted at 1e{exponent}");
1991        }
1992    }
1993
1994    /// The decoder requires every name the component carries, so a value the package emits
1995    /// cannot be left unread.
1996    #[tokio::test]
1997    async fn decoder_requires_every_attribute_the_component_carries() {
1998        for (id, names, build) in [
1999            (
2000                POOL_COMPONENT_ID,
2001                POOL_ATTRS.as_slice(),
2002                pool_attributes as fn(&EtherfiState) -> HashMap<String, Bytes>,
2003            ),
2004            (WRAPPER_COMPONENT_ID, WRAPPER_ATTRS.as_slice(), wrapper_attributes),
2005        ] {
2006            let state = if id == POOL_COMPONENT_ID { pool_state() } else { wrapper_state() };
2007            let full = build(&state);
2008            assert_eq!(full.len(), names.len(), "{id} carries a name outside its list");
2009            for name in names {
2010                let mut attributes = full.clone();
2011                attributes.remove(*name);
2012                let err = decode(snapshot(id, attributes))
2013                    .await
2014                    .unwrap_err();
2015                let InvalidSnapshotError::MissingAttribute(missing) = err else {
2016                    panic!("{name} removed but the decoder did not report it: {err:?}");
2017                };
2018                assert_eq!(&missing, name);
2019            }
2020        }
2021    }
2022
2023    /// Every name the component carries moves the state, so a delta the package sends cannot be
2024    /// silently dropped and left frozen at the snapshot value.
2025    #[test]
2026    fn delta_transition_applies_every_attribute_the_component_carries() {
2027        for base in [pool_state(), wrapper_state()] {
2028            let id = match base.venue {
2029                Venue::Pool(_) => POOL_COMPONENT_ID,
2030                Venue::Wrapper(_) => WRAPPER_COMPONENT_ID,
2031            };
2032            for name in attribute_names(&base) {
2033                let mut state = base.clone();
2034                // 7 fits every field and differs from every value in the fixtures.
2035                state
2036                    .delta_transition(
2037                        delta(id, vec![(name.to_string(), attribute(U256::from(7u64)))]),
2038                        &HashMap::new(),
2039                        &Balances::default(),
2040                    )
2041                    .unwrap_or_else(|e| panic!("{name} was rejected: {e:?}"));
2042                assert_ne!(state, base, "{name} left the state untouched");
2043            }
2044        }
2045    }
2046
2047    /// A name the other component carries is ignored, malformed or not. Each component decodes
2048    /// only its own names, so a package that starts emitting an attribute for one of them does
2049    /// not stop deltas reaching the other.
2050    #[test]
2051    fn delta_transition_ignores_the_other_components_names() {
2052        let mut pool = pool_state();
2053        let before = pool.clone();
2054        pool.delta_transition(
2055            delta(
2056                POOL_COMPONENT_ID,
2057                vec![(WEETH_SHARES_ATTR.to_string(), Bytes::from(vec![1u8; 33]))],
2058            ),
2059            &HashMap::new(),
2060            &Balances::default(),
2061        )
2062        .expect("a wrapper name leaves the pool alone");
2063        assert_eq!(pool, before);
2064
2065        let mut wrapper = wrapper_state();
2066        let before = wrapper.clone();
2067        wrapper
2068            .delta_transition(
2069                delta(
2070                    WRAPPER_COMPONENT_ID,
2071                    vec![(MINT_BUCKET.capacity.to_string(), Bytes::from(vec![1u8; 9]))],
2072                ),
2073                &HashMap::new(),
2074                &Balances::default(),
2075            )
2076            .expect("a pool name leaves the wrapper alone");
2077        assert_eq!(wrapper, before);
2078    }
2079
2080    /// The stream decoder puts the chain head in every delta. Those names are not EtherFi
2081    /// attributes and leave the state alone.
2082    #[test]
2083    fn delta_transition_accepts_the_injected_block_attributes() {
2084        let mut state = pool_state();
2085        state
2086            .delta_transition(
2087                delta(
2088                    POOL_COMPONENT_ID,
2089                    vec![
2090                        (
2091                            "block_number".to_string(),
2092                            Bytes::from(25_940_001u64.to_be_bytes().to_vec()),
2093                        ),
2094                        (
2095                            "block_timestamp".to_string(),
2096                            Bytes::from(BLOCK_TIMESTAMP.to_be_bytes().to_vec()),
2097                        ),
2098                    ],
2099                ),
2100                &HashMap::new(),
2101                &Balances::default(),
2102            )
2103            .expect("the injected names are tolerated");
2104        assert_eq!(state, pool_state());
2105    }
2106
2107    /// The ETH side has to be the address Tycho gives native ETH, not the router's
2108    /// 0xEeee..EEeE sentinel. The substreams package reports this address as a component token
2109    /// and `EtherfiSwapEncoder` compares against `Chain::native_token()`, so a mismatch here
2110    /// leaves the token unpriced and makes every ETH-side swap fail to encode.
2111    #[test]
2112    fn eth_address_is_the_chain_native_token() {
2113        assert_eq!(Bytes::from(ETH_ADDRESS), Chain::Ethereum.native_token().address);
2114    }
2115}