Skip to main content

tycho_simulation/evm/protocol/lido_v4/
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_mul_u256, safe_sub_u256},
19    u256_num::{biguint_to_u256, u256_to_biguint, u256_to_f64},
20};
21
22/// One component covers the whole venue: stETH mints, and wstETH wraps, unwraps and mints
23/// through `receive()`. Keyed by stETH, the contract that holds the pool.
24pub const STETH_COMPONENT_ID: &str = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84";
25
26pub const STETH_ADDRESS: [u8; 20] = hex!("ae7ab96520de3a18e5e111b5eaab095312d7fe84");
27pub const WSTETH_ADDRESS: [u8; 20] = hex!("7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0");
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 STETH: &[u8] = &STETH_ADDRESS;
33const WSTETH: &[u8] = &WSTETH_ADDRESS;
34
35// One attribute per value Lido names. The substreams package unpacks the storage words, so
36// each of these carries a single scalar.
37pub const TOTAL_SHARES_ATTR: &str = "total_shares";
38pub const EXTERNAL_SHARES_ATTR: &str = "external_shares";
39pub const BUFFERED_ETHER_ATTR: &str = "buffered_ether";
40pub const DEPOSITED_POST_REPORT_ATTR: &str = "deposited_post_report";
41pub const CL_VALIDATORS_BALANCE_ATTR: &str = "cl_validators_balance";
42pub const CL_PENDING_BALANCE_ATTR: &str = "cl_pending_balance";
43pub const PREV_STAKE_BLOCK_NUMBER_ATTR: &str = "prev_stake_block_number";
44pub const PREV_STAKE_LIMIT_ATTR: &str = "prev_stake_limit";
45pub const MAX_STAKE_LIMIT_GROWTH_BLOCKS_ATTR: &str = "max_stake_limit_growth_blocks";
46pub const MAX_STAKE_LIMIT_ATTR: &str = "max_stake_limit";
47pub const WSTETH_SHARES_ATTR: &str = "wsteth_shares";
48
49const UINT128_MAX_EXCLUSIVE: u128 = u128::MAX;
50
51// Gas each venue call costs, measured on a mainnet fork at block 25990000 from an account
52// trading for the first time, so every balance slot the call touches is cold. The 21,000
53// intrinsic and the calldata are left out: the router pays those once for the whole
54// transaction, and `estimate_gas_usage` adds the transfers around the leg separately.
55//
56// `wstETH.receive()` submits ETH and mints wstETH in one call. `wrap` additionally reads and
57// updates the stETH allowance during `transferFrom`.
58const SUBMIT_GAS: u64 = 83_000;
59const SUBMIT_AND_WRAP_GAS: u64 = 97_000;
60const WRAP_GAS: u64 = 103_000;
61const UNWRAP_GAS: u64 = 80_000;
62
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub struct LidoV4State {
65    /// Height of the block a quote is expected to execute in, maintained by `apply_block`.
66    ///
67    /// The stake limit accrues per block, so this tracks the chain head: `apply_block` advances
68    /// it on every message, including the blocks where stETH storage does not move, which are
69    /// most of them.
70    execution_block_number: u64,
71    total_shares: U256,
72    external_shares: U256,
73    buffered_ether: U256,
74    /// ETH sent to the deposit contract since the last oracle report; the next report moves it
75    /// into the consensus-layer balances below.
76    deposited_post_report: U256,
77    cl_validators_balance: U256,
78    cl_pending_balance: U256,
79    staking_state: StakingState,
80    /// `sharesOf(wstETH)`, i.e. the shares the wrapper holds. Bounds how much can be unwrapped.
81    wsteth_shares: U256,
82}
83
84/// Lido stores amounts as uint128 or narrower, so anything wider is a malformed input rather
85/// than a trade the venue could serve.
86fn validate_u128_bound(name: &str, value: U256) -> Result<(), SimulationError> {
87    if value >= U256::from(UINT128_MAX_EXCLUSIVE) {
88        return Err(SimulationError::InvalidInput(format!("{name} exceeds uint128 bound"), None));
89    }
90    Ok(())
91}
92
93/// Bytes an attribute occupies on the wire: the width of the stETH storage field the substreams
94/// package unpacks it from. The balances and share counts are `getLowAndHighUint128` halves, the
95/// stake limit fields are laid out by `StakeLimitUtils`, and `sharesOf(wstETH)` is a whole word.
96fn attribute_width(name: &str) -> Option<usize> {
97    match name {
98        TOTAL_SHARES_ATTR |
99        EXTERNAL_SHARES_ATTR |
100        BUFFERED_ETHER_ATTR |
101        DEPOSITED_POST_REPORT_ATTR |
102        CL_VALIDATORS_BALANCE_ATTR |
103        CL_PENDING_BALANCE_ATTR => Some(16),
104        PREV_STAKE_LIMIT_ATTR | MAX_STAKE_LIMIT_ATTR => Some(12),
105        PREV_STAKE_BLOCK_NUMBER_ATTR | MAX_STAKE_LIMIT_GROWTH_BLOCKS_ATTR => Some(4),
106        WSTETH_SHARES_ATTR => Some(32),
107        _ => None,
108    }
109}
110
111/// Reads a big-endian attribute, refusing one wider than its storage field. The package emits
112/// minimal-length values, so a wider one is malformed, and the `Err` names it for the caller to
113/// report.
114pub(super) fn decode_attribute(name: &str, value: &[u8]) -> Result<U256, String> {
115    let Some(width) = attribute_width(name) else {
116        return Err(format!("{name} is not a Lido V4 attribute"));
117    };
118    if value.len() > width {
119        return Err(format!("{name} is {} bytes, wider than its {width}-byte field", value.len()));
120    }
121    Ok(U256::from_be_slice(value))
122}
123
124/// `decode_attribute` for the two 32-bit block counters in `StakeLimitUtils`.
125pub(super) fn decode_u32_attribute(name: &str, value: &[u8]) -> Result<u32, String> {
126    let value = decode_attribute(name, value)?;
127    u32::try_from(value).map_err(|_| format!("{name} does not fit in 32 bits"))
128}
129
130#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131pub struct StakingState {
132    prev_stake_block_number: u32,
133    prev_stake_limit: U256,
134    max_stake_limit_growth_blocks: u32,
135    max_stake_limit: U256,
136}
137
138impl LidoV4State {
139    #[allow(clippy::too_many_arguments)]
140    pub fn new(
141        execution_block_number: u64,
142        total_shares: U256,
143        external_shares: U256,
144        buffered_ether: U256,
145        deposited_post_report: U256,
146        cl_validators_balance: U256,
147        cl_pending_balance: U256,
148        staking_state: StakingState,
149        wsteth_shares: U256,
150    ) -> Self {
151        Self {
152            execution_block_number,
153            total_shares,
154            external_shares,
155            buffered_ether,
156            deposited_post_report,
157            cl_validators_balance,
158            cl_pending_balance,
159            staking_state,
160            wsteth_shares,
161        }
162    }
163
164    fn internal_shares(&self) -> Result<U256, SimulationError> {
165        if self.external_shares > self.total_shares {
166            return Err(SimulationError::FatalError(
167                "external shares exceed total shares".to_string(),
168            ));
169        }
170        Ok(self.total_shares - self.external_shares)
171    }
172
173    /// `Lido._getInternalEther()`: buffered ether plus every balance counted on the consensus
174    /// layer - active validators, deposits pending activation, and deposits made since the last
175    /// oracle report. Each term is one half of a word read through `getLowAndHighUint128`, which
176    /// masks it to 128 bits, and `decode_attribute` holds it to that width, so four of them sum
177    /// below 2^130.
178    fn internal_ether(&self) -> U256 {
179        self.buffered_ether +
180            self.cl_validators_balance +
181            self.cl_pending_balance +
182            self.deposited_post_report
183    }
184
185    fn shares_for_pooled_eth(&self, eth_amount: U256) -> Result<U256, SimulationError> {
186        validate_u128_bound("eth amount", eth_amount)?;
187        let denominator = self.internal_shares()?;
188        let numerator = self.internal_ether();
189        if denominator.is_zero() || numerator.is_zero() {
190            return Err(SimulationError::FatalError("invalid Lido share rate state".to_string()));
191        }
192        Ok(safe_mul_u256(eth_amount, denominator)? / numerator)
193    }
194
195    fn pooled_eth_by_shares(&self, shares_amount: U256) -> Result<U256, SimulationError> {
196        validate_u128_bound("shares amount", shares_amount)?;
197        let numerator = self.internal_ether();
198        let denominator = self.internal_shares()?;
199        if denominator.is_zero() || numerator.is_zero() {
200            return Err(SimulationError::FatalError("invalid Lido share rate state".to_string()));
201        }
202        Ok(safe_mul_u256(shares_amount, numerator)? / denominator)
203    }
204
205    /// `stETH.totalSupply()`: `Lido._getTotalPooledEther()`, the internal ether plus the ether
206    /// backing shares minted outside the protocol, valued at the internal share rate.
207    fn total_pooled_ether(&self) -> Result<U256, SimulationError> {
208        let external_ether = self.pooled_eth_by_shares(self.external_shares)?;
209        safe_add_u256(self.internal_ether(), external_ether)
210    }
211
212    /// Caps deposits by staking capacity and the remaining uint128 storage capacity.
213    fn deposit_limit(&self) -> Result<U256, SimulationError> {
214        let cap = U256::from(u128::MAX);
215        let max_input = cap - U256::ONE;
216        let shares = self.internal_shares()?;
217        if shares.is_zero() || self.internal_ether().is_zero() {
218            return Err(SimulationError::FatalError("invalid Lido share rate state".to_string()));
219        }
220        let share_headroom = safe_sub_u256(cap, self.total_shares)?;
221        // The product can exceed 256 bits. Floor division gives a conservative deposit
222        // whose minted shares fit the field; cap before converting back to U256.
223        let share_capacity = u256_to_biguint(share_headroom) *
224            u256_to_biguint(self.internal_ether()) /
225            u256_to_biguint(shares);
226        let share_capacity = biguint_to_u256(&share_capacity.min(u256_to_biguint(max_input)));
227        Ok(self
228            .staking_state
229            .current_limit(self.execution_block_number)
230            .min(max_input)
231            .min(safe_sub_u256(cap, self.buffered_ether)?)
232            .min(share_capacity))
233    }
234
235    fn check_deposit_limit(&self, amount: U256) -> Result<(), SimulationError> {
236        if amount > self.deposit_limit()? {
237            return Err(SimulationError::RecoverableError("DEPOSIT_LIMIT".to_string()));
238        }
239        Ok(())
240    }
241
242    fn amount_out_eth_to_steth(
243        &self,
244        amount_in: U256,
245    ) -> Result<GetAmountOutResult, SimulationError> {
246        let shares_amount = self.shares_for_pooled_eth(amount_in)?;
247        let mut new_state = self.clone();
248        new_state
249            .staking_state
250            .decrease(amount_in, new_state.execution_block_number)?;
251        self.check_deposit_limit(amount_in)?;
252        new_state.total_shares = safe_add_u256(new_state.total_shares, shares_amount)?;
253        new_state.buffered_ether = safe_add_u256(new_state.buffered_ether, amount_in)?;
254        let amount_out = new_state.pooled_eth_by_shares(shares_amount)?;
255        Ok(GetAmountOutResult::new(
256            u256_to_biguint(amount_out),
257            BigUint::from(SUBMIT_GAS),
258            Box::new(new_state),
259        ))
260    }
261
262    fn amount_out_steth_to_wsteth(
263        &self,
264        amount_in: U256,
265    ) -> Result<GetAmountOutResult, SimulationError> {
266        if amount_in > self.total_pooled_ether()? {
267            return Err(SimulationError::RecoverableError("STETH_SUPPLY_EXCEEDED".to_string()));
268        }
269        let amount_out = self.shares_for_pooled_eth(amount_in)?;
270        // `wrap` pulls the stETH into the wrapper, so the shares it holds grow by what it minted.
271        let mut new_state = self.clone();
272        new_state.wsteth_shares = safe_add_u256(new_state.wsteth_shares, amount_out)?;
273        Ok(GetAmountOutResult::new(
274            u256_to_biguint(amount_out),
275            BigUint::from(WRAP_GAS),
276            Box::new(new_state),
277        ))
278    }
279
280    /// ETH -> wstETH through the wrapper's `receive()`: it submits the ETH and mints exactly the
281    /// shares `submit` returned, so the output is that share count.
282    fn amount_out_eth_to_wsteth(
283        &self,
284        amount_in: U256,
285    ) -> Result<GetAmountOutResult, SimulationError> {
286        let shares_amount = self.shares_for_pooled_eth(amount_in)?;
287        let mut new_state = self.clone();
288        new_state
289            .staking_state
290            .decrease(amount_in, new_state.execution_block_number)?;
291        self.check_deposit_limit(amount_in)?;
292        new_state.total_shares = safe_add_u256(new_state.total_shares, shares_amount)?;
293        new_state.buffered_ether = safe_add_u256(new_state.buffered_ether, amount_in)?;
294        // The submitted stETH lands on the wrapper, so its share balance grows with the mint.
295        new_state.wsteth_shares = safe_add_u256(new_state.wsteth_shares, shares_amount)?;
296        Ok(GetAmountOutResult::new(
297            u256_to_biguint(shares_amount),
298            BigUint::from(SUBMIT_AND_WRAP_GAS),
299            Box::new(new_state),
300        ))
301    }
302
303    fn unwrap_limit(&self) -> Result<U256, SimulationError> {
304        let max_input = U256::from(UINT128_MAX_EXCLUSIVE) - U256::ONE;
305        // stETH.transfer converts its nominal amount back to shares and bounds that amount.
306        Ok(self
307            .wsteth_shares
308            .min(max_input)
309            .min(self.shares_for_pooled_eth(max_input)?))
310    }
311
312    fn amount_out_wsteth_to_steth(
313        &self,
314        amount_in: U256,
315    ) -> Result<GetAmountOutResult, SimulationError> {
316        // The wrapper must hold enough shares, and the nominal stETH transfer must fit
317        // the contract's conversion bound. `get_limits` applies the same cap.
318        if amount_in > self.unwrap_limit()? {
319            return Err(SimulationError::RecoverableError("UNWRAP_LIMIT".to_string()));
320        }
321        let amount_out = self.pooled_eth_by_shares(amount_in)?;
322        // `unwrap` burns the caller's wstETH and pays out `amount_out` stETH, and `transfer`
323        // re-derives the shares that amount is worth. Both conversions round down, so what
324        // leaves the wrapper is what the round trip resolves to.
325        let mut new_state = self.clone();
326        let shares_paid_out = self.shares_for_pooled_eth(amount_out)?;
327        new_state.wsteth_shares = safe_sub_u256(new_state.wsteth_shares, shares_paid_out)?;
328        // A receiver's balance increase is at least the value of the transferred shares.
329        let amount_out = self.pooled_eth_by_shares(shares_paid_out)?;
330        Ok(GetAmountOutResult::new(
331            u256_to_biguint(amount_out),
332            BigUint::from(UNWRAP_GAS),
333            Box::new(new_state),
334        ))
335    }
336}
337
338impl StakingState {
339    pub(crate) fn new(
340        prev_stake_block_number: u32,
341        prev_stake_limit: U256,
342        max_stake_limit_growth_blocks: u32,
343        max_stake_limit: U256,
344    ) -> Self {
345        Self {
346            prev_stake_block_number,
347            prev_stake_limit,
348            max_stake_limit_growth_blocks,
349            max_stake_limit,
350        }
351    }
352
353    fn is_staking_paused(&self) -> bool {
354        self.prev_stake_block_number == 0
355    }
356
357    fn is_staking_limit_set(&self) -> bool {
358        !self.max_stake_limit.is_zero()
359    }
360
361    fn calculate_current_stake_limit(&self, block_number: u64) -> U256 {
362        let stake_limit_inc_per_block = if self.max_stake_limit_growth_blocks != 0 {
363            self.max_stake_limit / U256::from(self.max_stake_limit_growth_blocks)
364        } else {
365            U256::ZERO
366        };
367
368        let blocks_passed = block_number.saturating_sub(self.prev_stake_block_number as u64);
369        let change = U256::from(blocks_passed) * stake_limit_inc_per_block;
370
371        if self.prev_stake_limit < self.max_stake_limit {
372            (self.prev_stake_limit + change).min(self.max_stake_limit)
373        } else {
374            self.prev_stake_limit
375                .saturating_sub(change)
376                .max(self.max_stake_limit)
377        }
378    }
379
380    fn current_limit(&self, block_number: u64) -> U256 {
381        if self.is_staking_paused() {
382            U256::ZERO
383        } else if !self.is_staking_limit_set() {
384            U256::from(UINT128_MAX_EXCLUSIVE) - U256::ONE
385        } else {
386            self.calculate_current_stake_limit(block_number)
387        }
388    }
389
390    fn decrease(&mut self, amount: U256, block_number: u64) -> Result<(), SimulationError> {
391        if self.is_staking_paused() {
392            return Err(SimulationError::RecoverableError("STAKING_PAUSED".to_string()));
393        }
394
395        if self.is_staking_limit_set() {
396            let current_stake_limit = self.calculate_current_stake_limit(block_number);
397            if amount > current_stake_limit {
398                return Err(SimulationError::RecoverableError("STAKE_LIMIT".to_string()));
399            }
400            self.prev_stake_limit = current_stake_limit - amount;
401            self.prev_stake_block_number = block_number as u32;
402        }
403
404        Ok(())
405    }
406}
407
408#[typetag::serde]
409impl ProtocolSim for LidoV4State {
410    fn fee(&self) -> f64 {
411        0f64
412    }
413
414    /// Prices exactly the four directions the venue performs. stETH -> ETH and wstETH -> ETH run
415    /// through the asynchronous withdrawal queue, so they have no rate here, matching the zero
416    /// limit `get_limits` reports for them.
417    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
418        let quote_unit_f64 = u256_to_f64(U256::from(10).pow(U256::from(quote.decimals)))?;
419        let base_unit = U256::from(10).pow(U256::from(base.decimals));
420        let to_price = |amount_out: U256| -> Result<f64, SimulationError> {
421            Ok(u256_to_f64(amount_out)? / quote_unit_f64)
422        };
423
424        match (base.address.as_ref(), quote.address.as_ref()) {
425            // Submitting mints shares, and the depositor holds the stETH balance those shares
426            // are worth. Taken from the share rate, which holds while staking is paused or its
427            // limit is exhausted: those bound capacity, not price.
428            (ETH, STETH) => {
429                to_price(self.pooled_eth_by_shares(self.shares_for_pooled_eth(base_unit)?)?)
430            }
431            // Submitting ETH mints shares worth the ETH, and wrapping stETH mints shares worth the
432            // stETH - the same conversion either way, since submit is at parity.
433            (ETH | STETH, WSTETH) => to_price(self.shares_for_pooled_eth(base_unit)?),
434            (WSTETH, STETH) => to_price(self.pooled_eth_by_shares(base_unit)?),
435            _ => Err(SimulationError::FatalError("unsupported spot price".to_string())),
436        }
437    }
438
439    fn get_amount_out(
440        &self,
441        amount_in: BigUint,
442        token_in: &Token,
443        token_out: &Token,
444    ) -> Result<GetAmountOutResult, SimulationError> {
445        if amount_in.bits() > 128 {
446            return Err(SimulationError::InvalidInput(
447                "amount exceeds uint128 bound".to_string(),
448                None,
449            ));
450        }
451        let amount_in = biguint_to_u256(&amount_in);
452        validate_u128_bound("amount", amount_in)?;
453        // Every direction reverts on a zero amount: `submit` with ZERO_DEPOSIT, and the wrapper
454        // with its own zero-amount guards.
455        if amount_in.is_zero() {
456            return Err(SimulationError::RecoverableError("ZERO_AMOUNT".to_string()));
457        }
458
459        let result = match (token_in.address.as_ref(), token_out.address.as_ref()) {
460            (ETH, STETH) => self.amount_out_eth_to_steth(amount_in),
461            (STETH, WSTETH) => self.amount_out_steth_to_wsteth(amount_in),
462            (WSTETH, STETH) => self.amount_out_wsteth_to_steth(amount_in),
463            (ETH, WSTETH) => self.amount_out_eth_to_wsteth(amount_in),
464            _ => Err(SimulationError::FatalError("unsupported swap".to_string())),
465        }?;
466        if result.amount == BigUint::ZERO {
467            return Err(SimulationError::RecoverableError("ZERO_OUTPUT".to_string()));
468        }
469        Ok(result)
470    }
471
472    fn get_limits(
473        &self,
474        sell_token: Bytes,
475        buy_token: Bytes,
476    ) -> Result<(BigUint, BigUint), SimulationError> {
477        let max_input = U256::from(UINT128_MAX_EXCLUSIVE) - U256::ONE;
478        let max_sell = match (sell_token.as_ref(), buy_token.as_ref()) {
479            (ETH, STETH | WSTETH) => self.deposit_limit()?,
480            (STETH, WSTETH) => self
481                .total_pooled_ether()?
482                .min(max_input),
483            (WSTETH, STETH) => self.unwrap_limit()?,
484            // Unstaking requires the asynchronous withdrawal queue.
485            (STETH, ETH) | (WSTETH, ETH) => U256::ZERO,
486            _ => return Err(SimulationError::FatalError("unsupported swap".to_string())),
487        };
488        if max_sell.is_zero() {
489            return Ok((BigUint::ZERO, BigUint::ZERO));
490        }
491        let max_buy = match (sell_token.as_ref(), buy_token.as_ref()) {
492            (ETH, STETH) => {
493                self.amount_out_eth_to_steth(max_sell)?
494                    .amount
495            }
496            (ETH, WSTETH) => {
497                self.amount_out_eth_to_wsteth(max_sell)?
498                    .amount
499            }
500            (STETH, WSTETH) => {
501                self.amount_out_steth_to_wsteth(max_sell)?
502                    .amount
503            }
504            (WSTETH, STETH) => {
505                self.amount_out_wsteth_to_steth(max_sell)?
506                    .amount
507            }
508            _ => return Err(SimulationError::FatalError("unsupported swap".to_string())),
509        };
510        if max_buy == BigUint::ZERO {
511            return Ok((BigUint::ZERO, BigUint::ZERO));
512        }
513        Ok((u256_to_biguint(max_sell), max_buy))
514    }
515
516    fn delta_transition(
517        &mut self,
518        delta: ProtocolStateDelta,
519        _tokens: &HashMap<Bytes, Token>,
520        _balances: &Balances,
521    ) -> Result<(), TransitionError> {
522        let read = |name: &str| -> Result<Option<U256>, TransitionError> {
523            delta
524                .updated_attributes
525                .get(name)
526                .map(|value| decode_attribute(name, value))
527                .transpose()
528                .map_err(TransitionError::DecodeError)
529        };
530        let read_u32 = |name: &str| -> Result<Option<u32>, TransitionError> {
531            delta
532                .updated_attributes
533                .get(name)
534                .map(|value| decode_u32_attribute(name, value))
535                .transpose()
536                .map_err(TransitionError::DecodeError)
537        };
538
539        // Decode all fields before mutation so invalid deltas leave a consistent quote state.
540        let total_shares = read(TOTAL_SHARES_ATTR)?;
541        let external_shares = read(EXTERNAL_SHARES_ATTR)?;
542        let buffered_ether = read(BUFFERED_ETHER_ATTR)?;
543        let deposited_post_report = read(DEPOSITED_POST_REPORT_ATTR)?;
544        let cl_validators_balance = read(CL_VALIDATORS_BALANCE_ATTR)?;
545        let cl_pending_balance = read(CL_PENDING_BALANCE_ATTR)?;
546        let wsteth_shares = read(WSTETH_SHARES_ATTR)?;
547        let prev_stake_block_number = read_u32(PREV_STAKE_BLOCK_NUMBER_ATTR)?;
548        let prev_stake_limit = read(PREV_STAKE_LIMIT_ATTR)?;
549        let max_stake_limit_growth_blocks = read_u32(MAX_STAKE_LIMIT_GROWTH_BLOCKS_ATTR)?;
550        let max_stake_limit = read(MAX_STAKE_LIMIT_ATTR)?;
551
552        if let Some(value) = total_shares {
553            self.total_shares = value;
554        }
555        if let Some(value) = external_shares {
556            self.external_shares = value;
557        }
558        if let Some(value) = buffered_ether {
559            self.buffered_ether = value;
560        }
561        if let Some(value) = deposited_post_report {
562            self.deposited_post_report = value;
563        }
564        if let Some(value) = cl_validators_balance {
565            self.cl_validators_balance = value;
566        }
567        if let Some(value) = cl_pending_balance {
568            self.cl_pending_balance = value;
569        }
570        if let Some(value) = wsteth_shares {
571            self.wsteth_shares = value;
572        }
573        if let Some(value) = prev_stake_block_number {
574            self.staking_state
575                .prev_stake_block_number = value;
576        }
577        if let Some(value) = prev_stake_limit {
578            self.staking_state.prev_stake_limit = value;
579        }
580        if let Some(value) = max_stake_limit_growth_blocks {
581            self.staking_state
582                .max_stake_limit_growth_blocks = value;
583        }
584        if let Some(value) = max_stake_limit {
585            self.staking_state.max_stake_limit = value;
586        }
587        Ok(())
588    }
589
590    /// Advances to the block a quote would execute in, so the stake limit keeps accruing on the
591    /// blocks where Lido's own storage did not move.
592    ///
593    /// Re-emits only when the resolved limit actually changed: a repeated block short-circuits,
594    /// and once the limit has settled at `max_stake_limit` further blocks cost one virtual call
595    /// and no clone.
596    fn apply_block(&mut self, block: &BlockContext) -> bool {
597        let number = block.number();
598        if number == self.execution_block_number {
599            return false;
600        }
601        let limit_before = self
602            .staking_state
603            .current_limit(self.execution_block_number);
604        self.execution_block_number = number;
605        limit_before != self.staking_state.current_limit(number)
606    }
607
608    fn query_pool_swap(
609        &self,
610        params: &tycho_common::simulation::protocol_sim::QueryPoolSwapParams,
611    ) -> Result<tycho_common::simulation::protocol_sim::PoolSwap, SimulationError> {
612        crate::evm::query_pool_swap::query_pool_swap(self, params)
613    }
614
615    fn clone_box(&self) -> Box<dyn ProtocolSim> {
616        Box::new(self.clone())
617    }
618
619    fn as_any(&self) -> &dyn Any {
620        self
621    }
622
623    fn as_any_mut(&mut self) -> &mut dyn Any {
624        self
625    }
626
627    fn eq(&self, other: &dyn ProtocolSim) -> bool {
628        other.as_any().downcast_ref::<Self>() == Some(self)
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    /// The attributes the component carries, in the order the decoder reads them.
635    pub(super) const COMPONENT_ATTRS: [&str; 11] = [
636        TOTAL_SHARES_ATTR,
637        EXTERNAL_SHARES_ATTR,
638        BUFFERED_ETHER_ATTR,
639        DEPOSITED_POST_REPORT_ATTR,
640        CL_VALIDATORS_BALANCE_ATTR,
641        CL_PENDING_BALANCE_ATTR,
642        PREV_STAKE_BLOCK_NUMBER_ATTR,
643        PREV_STAKE_LIMIT_ATTR,
644        MAX_STAKE_LIMIT_GROWTH_BLOCKS_ATTR,
645        MAX_STAKE_LIMIT_ATTR,
646        WSTETH_SHARES_ATTR,
647    ];
648    use std::collections::HashMap;
649
650    use tycho_client::feed::BlockHeader;
651    use tycho_common::{
652        dto::ProtocolStateDelta,
653        models::{
654            protocol::{ProtocolComponent, ProtocolComponentState},
655            Chain,
656        },
657        simulation::errors::{SimulationError, TransitionError},
658        Bytes,
659    };
660
661    use super::*;
662    use crate::{
663        evm::protocol::test_utils::try_decode_snapshot_with_defaults,
664        protocol::{errors::InvalidSnapshotError, models::TryFromWithBlock},
665    };
666
667    fn eth_token() -> Token {
668        Token::new(&Bytes::from(ETH_ADDRESS), "ETH", 18, 0, &[], Chain::Ethereum, 100)
669    }
670
671    fn steth_token() -> Token {
672        Token::new(&Bytes::from(STETH_ADDRESS), "stETH", 18, 0, &[], Chain::Ethereum, 75)
673    }
674
675    fn wsteth_token() -> Token {
676        Token::new(&Bytes::from(WSTETH_ADDRESS), "wstETH", 18, 0, &[], Chain::Ethereum, 100)
677    }
678
679    fn sample_staking_state() -> StakingState {
680        StakingState {
681            prev_stake_block_number: 24_083_113,
682            prev_stake_limit: U256::from(1_000u64) * U256::from(10).pow(U256::from(18)),
683            max_stake_limit_growth_blocks: 10,
684            max_stake_limit: U256::from(1_000u64) * U256::from(10).pow(U256::from(18)),
685        }
686    }
687
688    fn sample_state() -> LidoV4State {
689        LidoV4State::new(
690            24_083_113,
691            U256::from_str_radix("6696604823358181328750512", 10).unwrap(),
692            U256::from_str_radix("80758346894447149184", 10).unwrap(),
693            U256::from_str_radix("658338852056838456032283", 10).unwrap(),
694            U256::from(30_560u64) * U256::from(10).pow(U256::from(18)),
695            U256::from_str_radix("21114116614166341429013364", 10).unwrap(),
696            U256::ZERO,
697            sample_staking_state(),
698            sample_wsteth_shares(),
699        )
700    }
701
702    /// The shares the wstETH wrapper holds - a little under half the pool.
703    fn sample_wsteth_shares() -> U256 {
704        U256::from_str_radix("2960000000000000000000000", 10).unwrap()
705    }
706
707    /// Encodes an attribute the way the substreams package does: big-endian with no leading zero
708    /// bytes, and a single zero byte for zero.
709    fn attribute(value: U256) -> Bytes {
710        let bytes = value.to_be_bytes_vec();
711        let start = bytes
712            .iter()
713            .position(|byte| *byte != 0)
714            .unwrap_or(bytes.len() - 1);
715        Bytes::from(bytes[start..].to_vec())
716    }
717
718    fn snapshot() -> tycho_client::feed::synchronizer::ComponentWithState {
719        let state = sample_state();
720        let staking = sample_staking_state();
721        let attributes: HashMap<String, Bytes> = [
722            (TOTAL_SHARES_ATTR, state.total_shares),
723            (EXTERNAL_SHARES_ATTR, state.external_shares),
724            (BUFFERED_ETHER_ATTR, state.buffered_ether),
725            (DEPOSITED_POST_REPORT_ATTR, state.deposited_post_report),
726            (CL_VALIDATORS_BALANCE_ATTR, state.cl_validators_balance),
727            (CL_PENDING_BALANCE_ATTR, state.cl_pending_balance),
728            (WSTETH_SHARES_ATTR, sample_wsteth_shares()),
729            (PREV_STAKE_BLOCK_NUMBER_ATTR, U256::from(staking.prev_stake_block_number)),
730            (PREV_STAKE_LIMIT_ATTR, staking.prev_stake_limit),
731            (MAX_STAKE_LIMIT_GROWTH_BLOCKS_ATTR, U256::from(staking.max_stake_limit_growth_blocks)),
732            (MAX_STAKE_LIMIT_ATTR, staking.max_stake_limit),
733        ]
734        .into_iter()
735        .map(|(name, value)| (name.to_string(), attribute(value)))
736        .collect();
737        let component_id = STETH_COMPONENT_ID.to_string();
738
739        tycho_client::feed::synchronizer::ComponentWithState {
740            state: ProtocolComponentState {
741                component_id: component_id.clone(),
742                attributes,
743                balances: HashMap::new(),
744            },
745            component: ProtocolComponent {
746                id: component_id,
747                protocol_system: "lido_v4".to_string(),
748                protocol_type_name: "lido_v4_pool".to_string(),
749                chain: Chain::Ethereum,
750                tokens: Vec::new(),
751                contract_addresses: Vec::new(),
752                static_attributes: HashMap::new(),
753                change: Default::default(),
754                creation_tx: Bytes::new(),
755                created_at: chrono::DateTime::UNIX_EPOCH.naive_utc(),
756            },
757            component_tvl: None,
758            entrypoints: Vec::new(),
759        }
760    }
761
762    /// One component carries every attribute, so the decoded state is complete for all four
763    /// directions - the stake limit that bounds the two submit paths and the wrapper's shares
764    /// that bound unwrapping.
765    #[tokio::test]
766    async fn decoder_reads_the_snapshot() {
767        let state = try_decode_snapshot_with_defaults::<LidoV4State>(snapshot())
768            .await
769            .unwrap();
770        let expected = sample_state();
771
772        assert_eq!(state.total_shares, expected.total_shares);
773        assert_eq!(state.external_shares, expected.external_shares);
774        assert_eq!(state.buffered_ether, expected.buffered_ether);
775        assert_eq!(state.deposited_post_report, expected.deposited_post_report);
776        assert_eq!(state.cl_validators_balance, expected.cl_validators_balance);
777        assert_eq!(state.cl_pending_balance, expected.cl_pending_balance);
778        assert_eq!(state.staking_state, expected.staking_state);
779        assert_eq!(state.wsteth_shares, expected.wsteth_shares);
780    }
781
782    /// A whole word where the package emits a `getLowAndHighUint128` half cannot be a value the
783    /// package produced, so the decoder reports it by name.
784    #[tokio::test]
785    async fn decoder_rejects_an_attribute_wider_than_its_field() {
786        let mut snapshot = snapshot();
787        snapshot.state.attributes.insert(
788            TOTAL_SHARES_ATTR.to_string(),
789            Bytes::from(
790                sample_state()
791                    .total_shares
792                    .to_be_bytes_vec(),
793            ),
794        );
795
796        let err = try_decode_snapshot_with_defaults::<LidoV4State>(snapshot)
797            .await
798            .unwrap_err();
799
800        let InvalidSnapshotError::ValueError(message) = err else {
801            panic!("expected a value error, got {err:?}");
802        };
803        assert!(message.contains(TOTAL_SHARES_ATTR), "{message}");
804    }
805
806    /// The width of every attribute is the width of the stETH field it is unpacked from, so each
807    /// one has to be pinned on its own: a width that is too generous accepts a value the field
808    /// cannot hold, and one that is too tight rejects a value the package legitimately emits.
809    #[test]
810    fn every_attribute_is_read_at_the_width_of_its_field() {
811        let widths: HashMap<&str, usize> = HashMap::from([
812            (TOTAL_SHARES_ATTR, 16),
813            (EXTERNAL_SHARES_ATTR, 16),
814            (BUFFERED_ETHER_ATTR, 16),
815            (DEPOSITED_POST_REPORT_ATTR, 16),
816            (CL_VALIDATORS_BALANCE_ATTR, 16),
817            (CL_PENDING_BALANCE_ATTR, 16),
818            (PREV_STAKE_BLOCK_NUMBER_ATTR, 4),
819            (MAX_STAKE_LIMIT_GROWTH_BLOCKS_ATTR, 4),
820            (PREV_STAKE_LIMIT_ATTR, 12),
821            (MAX_STAKE_LIMIT_ATTR, 12),
822            (WSTETH_SHARES_ATTR, 32),
823        ]);
824        assert_eq!(widths.len(), COMPONENT_ATTRS.len());
825
826        for name in COMPONENT_ATTRS {
827            let width = widths[name];
828            decode_attribute(name, &vec![0xffu8; width])
829                .unwrap_or_else(|e| panic!("{name} rejects a full {width}-byte field: {e}"));
830            let err = decode_attribute(name, &vec![0xffu8; width + 1])
831                .expect_err("a value wider than the field is malformed");
832            assert!(err.contains(name), "{err}");
833        }
834    }
835
836    #[tokio::test]
837    async fn decoder_rejects_an_unknown_component_id() {
838        let mut snapshot = snapshot();
839        snapshot.component.id = "0xdeadbeef".to_string();
840
841        assert!(try_decode_snapshot_with_defaults::<LidoV4State>(snapshot)
842            .await
843            .is_err());
844    }
845
846    #[test]
847    fn eth_to_steth_updates_state_and_consumes_stake_limit() {
848        let state = sample_state();
849        let amount_in = BigUint::from(10u64).pow(18);
850        let result = state
851            .get_amount_out(amount_in.clone(), &eth_token(), &steth_token())
852            .unwrap();
853
854        assert!(result.amount > BigUint::ZERO);
855        let new_state = result
856            .new_state
857            .as_any()
858            .downcast_ref::<LidoV4State>()
859            .unwrap();
860        assert_eq!(
861            new_state.buffered_ether,
862            state.buffered_ether + U256::from(10).pow(U256::from(18))
863        );
864        assert!(new_state.total_shares > state.total_shares);
865        let old_limit = state
866            .staking_state
867            .current_limit(state.execution_block_number);
868        let new_limit = new_state
869            .staking_state
870            .current_limit(new_state.execution_block_number);
871        assert!(new_limit < old_limit);
872    }
873
874    /// Both legs move the stETH the wrapper holds, and that balance is what bounds unwrapping,
875    /// so a consumer walking the returned state has to see it change.
876    #[test]
877    fn wrapping_and_unwrapping_move_the_wrapper_shares() {
878        let state = sample_state();
879        let amount_in = BigUint::from(10u64).pow(18);
880
881        let wrap = state
882            .get_amount_out(amount_in.clone(), &steth_token(), &wsteth_token())
883            .expect("wrap");
884        let wrapped = wrap
885            .new_state
886            .as_any()
887            .downcast_ref::<LidoV4State>()
888            .unwrap();
889        // `wrap` pulls the stETH in, so the wrapper holds the shares it just minted on top.
890        assert_eq!(wrapped.wsteth_shares, state.wsteth_shares + biguint_to_u256(&wrap.amount));
891
892        let unwrap = state
893            .get_amount_out(amount_in.clone(), &wsteth_token(), &steth_token())
894            .expect("unwrap");
895        let unwrapped = unwrap
896            .new_state
897            .as_any()
898            .downcast_ref::<LidoV4State>()
899            .unwrap();
900        // `unwrap` pays the stETH out and `transfer` re-derives the shares it is worth. Both
901        // conversions round down, so the wrapper keeps one wei-share of what was burnt.
902        assert_eq!(
903            unwrapped.wsteth_shares,
904            state.wsteth_shares - biguint_to_u256(&amount_in) + U256::ONE
905        );
906    }
907
908    /// Draining the wrapper moves the bound down to what is left, which is the dust the two
909    /// roundings strand and stETH cannot pay out.
910    #[test]
911    fn unwrapping_the_whole_wrapper_strands_the_rounding_dust() {
912        let state = sample_state();
913        let (max_in, _) = state
914            .get_limits(Bytes::from(WSTETH_ADDRESS), Bytes::from(STETH_ADDRESS))
915            .expect("limits");
916
917        let drained = state
918            .get_amount_out(max_in.clone(), &wsteth_token(), &steth_token())
919            .expect("drain");
920        let drained = drained
921            .new_state
922            .as_any()
923            .downcast_ref::<LidoV4State>()
924            .unwrap();
925
926        assert_eq!(drained.wsteth_shares, U256::ONE);
927        let (drained_max_in, _) = drained
928            .get_limits(Bytes::from(WSTETH_ADDRESS), Bytes::from(STETH_ADDRESS))
929            .expect("limits");
930        assert_eq!(drained_max_in, BigUint::ZERO);
931        // And a second unwrap of the original size is refused.
932        assert!(drained
933            .get_amount_out(max_in, &wsteth_token(), &steth_token())
934            .is_err());
935    }
936
937    #[test]
938    fn unwrap_quotes_transferred_shares_and_rejects_zero_receipts() {
939        let mut state = sample_state();
940        state.total_shares = U256::from(10);
941        state.external_shares = U256::ZERO;
942        state.buffered_ether = U256::from(15);
943        state.deposited_post_report = U256::ZERO;
944        state.cl_validators_balance = U256::ZERO;
945        state.cl_pending_balance = U256::ZERO;
946        state.wsteth_shares = U256::from(3);
947        let quote = state
948            .get_amount_out(BigUint::from(3u8), &wsteth_token(), &steth_token())
949            .unwrap();
950        assert_eq!(quote.amount, BigUint::from(3u8));
951        assert_eq!(
952            state
953                .get_limits(Bytes::from(WSTETH_ADDRESS), Bytes::from(STETH_ADDRESS))
954                .unwrap(),
955            (BigUint::from(3u8), BigUint::from(3u8))
956        );
957        assert!(
958            matches!(state.get_amount_out(BigUint::from(1u8), &wsteth_token(), &steth_token()),
959            Err(SimulationError::RecoverableError(message)) if message == "ZERO_OUTPUT")
960        );
961        state.wsteth_shares = U256::ONE;
962        assert_eq!(
963            state
964                .get_limits(Bytes::from(WSTETH_ADDRESS), Bytes::from(STETH_ADDRESS))
965                .unwrap(),
966            (BigUint::ZERO, BigUint::ZERO)
967        );
968    }
969
970    #[test]
971    fn unwrap_limit_respects_the_steth_transfer_amount_bound() {
972        let mut state = sample_state();
973        let cap = U256::from(u128::MAX);
974        state.total_shares = cap / U256::from(2);
975        state.external_shares = U256::ZERO;
976        state.buffered_ether = cap;
977        state.deposited_post_report = U256::ZERO;
978        state.cl_validators_balance = U256::ZERO;
979        state.cl_pending_balance = U256::ZERO;
980        state.wsteth_shares = state.total_shares;
981        let (max_sell, max_buy) = state
982            .get_limits(Bytes::from(WSTETH_ADDRESS), Bytes::from(STETH_ADDRESS))
983            .unwrap();
984        assert_eq!(max_sell, u256_to_biguint(state.total_shares - U256::ONE));
985        assert_eq!(max_buy, u256_to_biguint(cap - U256::from(5)));
986        let quote = state
987            .get_amount_out(max_sell.clone(), &wsteth_token(), &steth_token())
988            .unwrap();
989        assert_eq!(quote.amount, max_buy);
990        assert!(state
991            .get_amount_out(max_sell + BigUint::from(1u8), &wsteth_token(), &steth_token())
992            .is_err());
993    }
994
995    #[test]
996    fn oversized_input_returns_an_error() {
997        assert!(matches!(
998            sample_state().get_amount_out(
999                BigUint::from(1u8) << 256usize,
1000                &eth_token(),
1001                &steth_token()
1002            ),
1003            Err(SimulationError::InvalidInput(_, _))
1004        ));
1005    }
1006
1007    #[test]
1008    fn wrap_refuses_more_than_the_reported_supply() {
1009        let state = sample_state();
1010        let (limit, _) = state
1011            .get_limits(Bytes::from(STETH_ADDRESS), Bytes::from(WSTETH_ADDRESS))
1012            .unwrap();
1013        assert!(state
1014            .get_amount_out(limit + BigUint::from(1u8), &steth_token(), &wsteth_token())
1015            .is_err());
1016    }
1017
1018    #[test]
1019    fn mint_limits_respect_storage_headroom() {
1020        let cap = U256::from(u128::MAX);
1021        for (shares, buffer, validators, expected_limit) in [
1022            (cap - U256::from(10), U256::ONE, cap - U256::from(11), 10u64),
1023            (cap - U256::from(100), cap - U256::from(10), U256::ZERO, 10),
1024            (cap - U256::from(10), U256::from(100), U256::ZERO, 0),
1025            (U256::from(100), cap - U256::from(10), U256::ZERO, 0),
1026        ] {
1027            let mut state = sample_state();
1028            state.total_shares = shares;
1029            state.external_shares = U256::ZERO;
1030            state.buffered_ether = buffer;
1031            state.deposited_post_report = U256::ZERO;
1032            state.cl_validators_balance = validators;
1033            state.cl_pending_balance = U256::ZERO;
1034            state.staking_state.max_stake_limit = U256::ZERO;
1035            for output in [steth_token(), wsteth_token()] {
1036                let (limit, _) = state
1037                    .get_limits(Bytes::from(ETH_ADDRESS), output.address.clone())
1038                    .unwrap();
1039                assert_eq!(limit, BigUint::from(expected_limit));
1040                if limit != BigUint::ZERO {
1041                    let quote = state
1042                        .get_amount_out(limit.clone(), &eth_token(), &output)
1043                        .unwrap();
1044                    let next = quote
1045                        .new_state
1046                        .as_any()
1047                        .downcast_ref::<LidoV4State>()
1048                        .unwrap();
1049                    assert!(next.total_shares <= cap);
1050                    assert!(next.buffered_ether <= cap);
1051                }
1052                assert!(state
1053                    .get_amount_out(limit + BigUint::from(1u8), &eth_token(), &output)
1054                    .is_err());
1055            }
1056        }
1057    }
1058
1059    /// Every direction reverts on chain at a zero amount.
1060    #[test]
1061    fn zero_amount_is_refused_in_every_direction() {
1062        let state = sample_state();
1063        for (token_in, token_out) in [
1064            (eth_token(), steth_token()),
1065            (eth_token(), wsteth_token()),
1066            (steth_token(), wsteth_token()),
1067            (wsteth_token(), steth_token()),
1068        ] {
1069            let err = state
1070                .get_amount_out(BigUint::ZERO, &token_in, &token_out)
1071                .unwrap_err();
1072            assert!(
1073                matches!(err, SimulationError::RecoverableError(ref m) if m == "ZERO_AMOUNT"),
1074                "{} -> {} quoted a zero amount",
1075                token_in.symbol,
1076                token_out.symbol
1077            );
1078        }
1079    }
1080
1081    #[test]
1082    fn spot_price_covers_every_tradable_direction() {
1083        let state = sample_state();
1084
1085        // Submitting is at parity, and wrapping is the share rate, so the two wstETH legs agree.
1086        let steth_per_eth = state
1087            .spot_price(&eth_token(), &steth_token())
1088            .expect("ETH -> stETH price");
1089        let wsteth_per_eth = state
1090            .spot_price(&eth_token(), &wsteth_token())
1091            .expect("ETH -> wstETH price");
1092        let wsteth_per_steth = state
1093            .spot_price(&steth_token(), &wsteth_token())
1094            .expect("stETH -> wstETH price");
1095        let steth_per_wsteth = state
1096            .spot_price(&wsteth_token(), &steth_token())
1097            .expect("wstETH -> stETH price");
1098
1099        assert!((steth_per_eth - 1.0).abs() < 1e-6, "submit off parity: {steth_per_eth}");
1100        assert_eq!(wsteth_per_eth, wsteth_per_steth);
1101        assert!((wsteth_per_steth * steth_per_wsteth - 1.0).abs() < 1e-9);
1102    }
1103
1104    #[test]
1105    fn spot_price_survives_exhausted_staking_capacity() {
1106        let mut state = sample_state();
1107        let mut staking_state = state.staking_state;
1108        // Capacity gone, but the pair still has a rate: the limit bounds size, not price.
1109        staking_state.prev_stake_limit = U256::ZERO;
1110        staking_state.prev_stake_block_number = state.execution_block_number as u32;
1111        staking_state.max_stake_limit_growth_blocks = 0;
1112        state.staking_state = staking_state;
1113
1114        // A quote is unavailable ...
1115        assert!(state
1116            .get_amount_out(BigUint::from(10u64).pow(18), &eth_token(), &steth_token())
1117            .is_err());
1118        // ... but the price still resolves, for both submit paths.
1119        assert!(state
1120            .spot_price(&eth_token(), &steth_token())
1121            .is_ok());
1122        assert!(state
1123            .spot_price(&eth_token(), &wsteth_token())
1124            .is_ok());
1125    }
1126
1127    #[test]
1128    fn spot_price_rejects_a_token_the_venue_does_not_hold() {
1129        let weth = Token::new(&Bytes::from([0xc0u8; 20]), "WETH", 18, 0, &[], Chain::Ethereum, 100);
1130
1131        assert!(sample_state()
1132            .spot_price(&weth, &steth_token())
1133            .is_err());
1134    }
1135
1136    #[test]
1137    fn get_limits_unwrap_is_bounded_by_wrapper_shares() {
1138        let state = sample_state();
1139
1140        let (max_in, max_out) = state
1141            .get_limits(Bytes::from(WSTETH_ADDRESS), Bytes::from(STETH_ADDRESS))
1142            .unwrap();
1143
1144        // Unwrapping pays out of the wrapper's stETH, so the limit is its share balance.
1145        assert_eq!(max_in, u256_to_biguint(sample_wsteth_shares()));
1146        assert_eq!(
1147            max_out,
1148            u256_to_biguint(
1149                state
1150                    .pooled_eth_by_shares(
1151                        state
1152                            .shares_for_pooled_eth(
1153                                state
1154                                    .pooled_eth_by_shares(sample_wsteth_shares())
1155                                    .unwrap()
1156                            )
1157                            .unwrap()
1158                    )
1159                    .unwrap()
1160            )
1161        );
1162        assert!(max_in < u256_to_biguint(U256::from(UINT128_MAX_EXCLUSIVE) - U256::ONE));
1163    }
1164
1165    #[test]
1166    fn get_limits_wrap_is_bounded_by_steth_supply() {
1167        let state = sample_state();
1168
1169        let (max_in, max_out) = state
1170            .get_limits(Bytes::from(STETH_ADDRESS), Bytes::from(WSTETH_ADDRESS))
1171            .unwrap();
1172
1173        // No more stETH can be wrapped than exists, and what exists is `totalSupply()`: the
1174        // internal ether plus the ether backing the externally minted shares.
1175        let supply = U256::from_str_radix("21803278404946205780741210", 10).expect("supply");
1176        assert_eq!(max_in, u256_to_biguint(supply));
1177        assert!(max_in > u256_to_biguint(state.internal_ether()), "external ether is missing");
1178        // Wrapping the whole supply mints every share but the one the round trip rounds away.
1179        assert_eq!(max_out, u256_to_biguint(state.total_shares - U256::ONE));
1180        assert!(max_in < u256_to_biguint(U256::from(UINT128_MAX_EXCLUSIVE) - U256::ONE));
1181    }
1182
1183    #[test]
1184    fn get_limits_unwrap_with_empty_wrapper_returns_zero() {
1185        let mut state = sample_state();
1186        state.wsteth_shares = U256::ZERO;
1187
1188        let (max_in, max_out) = state
1189            .get_limits(Bytes::from(WSTETH_ADDRESS), Bytes::from(STETH_ADDRESS))
1190            .unwrap();
1191
1192        assert_eq!(max_in, BigUint::ZERO);
1193        assert_eq!(max_out, BigUint::ZERO);
1194    }
1195
1196    #[test]
1197    fn decoder_reads_wsteth_shares() {
1198        let state = sample_state();
1199        assert_eq!(state.wsteth_shares, sample_wsteth_shares());
1200    }
1201
1202    #[test]
1203    fn get_limits_unsupported_direction_returns_zero() {
1204        let state = sample_state();
1205
1206        let (max_in, max_out) = state
1207            .get_limits(Bytes::from(STETH_ADDRESS), Bytes::from(ETH_ADDRESS))
1208            .expect("limits");
1209
1210        assert_eq!(max_in, BigUint::ZERO);
1211        assert_eq!(max_out, BigUint::ZERO);
1212    }
1213
1214    #[test]
1215    fn get_limits_respects_current_stake_limit() {
1216        let state = sample_state();
1217        let (max_in, max_out) = state
1218            .get_limits(Bytes::from(ETH_ADDRESS), Bytes::from(STETH_ADDRESS))
1219            .unwrap();
1220
1221        assert_eq!(
1222            max_in,
1223            u256_to_biguint(
1224                state
1225                    .staking_state
1226                    .current_limit(state.execution_block_number)
1227            )
1228        );
1229        assert!(max_out > BigUint::ZERO);
1230    }
1231
1232    #[test]
1233    fn paused_staking_blocks_eth_to_steth() {
1234        let mut state = sample_state();
1235        let mut staking_state = state.staking_state;
1236        staking_state.prev_stake_block_number = 0;
1237        state.staking_state = staking_state;
1238
1239        let err = state
1240            .get_amount_out(BigUint::from(10u64).pow(18), &eth_token(), &steth_token())
1241            .unwrap_err();
1242
1243        assert!(
1244            matches!(err, SimulationError::RecoverableError(ref msg) if msg == "STAKING_PAUSED")
1245        );
1246    }
1247
1248    #[test]
1249    fn delta_transition_updates_state() {
1250        let mut state = sample_state();
1251        let new_total = U256::from(999u64);
1252        let new_external = U256::from(111u64);
1253        let new_buffered = U256::from(222u64);
1254        let new_deposited_post_report = U256::from(333u64);
1255        let new_cl_validators_balance = U256::from(444u64);
1256        let new_cl_pending_balance = U256::from(555u64);
1257        let new_staking_state = StakingState {
1258            prev_stake_block_number: 77,
1259            prev_stake_limit: U256::from(888u64),
1260            max_stake_limit_growth_blocks: 9,
1261            max_stake_limit: U256::from(999u64),
1262        };
1263
1264        state
1265            .delta_transition(
1266                ProtocolStateDelta {
1267                    component_id: STETH_COMPONENT_ID.to_string(),
1268                    updated_attributes: HashMap::from([
1269                        (TOTAL_SHARES_ATTR.to_string(), attribute(new_total)),
1270                        (EXTERNAL_SHARES_ATTR.to_string(), attribute(new_external)),
1271                        (BUFFERED_ETHER_ATTR.to_string(), attribute(new_buffered)),
1272                        (
1273                            DEPOSITED_POST_REPORT_ATTR.to_string(),
1274                            attribute(new_deposited_post_report),
1275                        ),
1276                        (
1277                            CL_VALIDATORS_BALANCE_ATTR.to_string(),
1278                            attribute(new_cl_validators_balance),
1279                        ),
1280                        (CL_PENDING_BALANCE_ATTR.to_string(), attribute(new_cl_pending_balance)),
1281                        (
1282                            PREV_STAKE_BLOCK_NUMBER_ATTR.to_string(),
1283                            attribute(U256::from(new_staking_state.prev_stake_block_number)),
1284                        ),
1285                        (
1286                            PREV_STAKE_LIMIT_ATTR.to_string(),
1287                            attribute(new_staking_state.prev_stake_limit),
1288                        ),
1289                        (
1290                            MAX_STAKE_LIMIT_GROWTH_BLOCKS_ATTR.to_string(),
1291                            attribute(U256::from(new_staking_state.max_stake_limit_growth_blocks)),
1292                        ),
1293                        (
1294                            MAX_STAKE_LIMIT_ATTR.to_string(),
1295                            attribute(new_staking_state.max_stake_limit),
1296                        ),
1297                    ]),
1298                    deleted_attributes: Default::default(),
1299                },
1300                &HashMap::new(),
1301                &Balances::default(),
1302            )
1303            .unwrap();
1304
1305        assert_eq!(state.total_shares, new_total);
1306        assert_eq!(state.external_shares, new_external);
1307        assert_eq!(state.buffered_ether, new_buffered);
1308        assert_eq!(state.deposited_post_report, new_deposited_post_report);
1309        assert_eq!(state.cl_validators_balance, new_cl_validators_balance);
1310        assert_eq!(state.cl_pending_balance, new_cl_pending_balance);
1311        assert_eq!(state.staking_state, new_staking_state);
1312    }
1313
1314    /// `prev_stake_block_number` is a 32-bit field. A five-byte value cannot come from the
1315    /// package, and narrowing it would wrap the block the stake limit accrues from.
1316    #[test]
1317    fn delta_transition_rejects_a_block_number_wider_than_its_field() {
1318        let mut state = sample_state();
1319        let before = state.clone();
1320
1321        let err = state
1322            .delta_transition(
1323                ProtocolStateDelta {
1324                    component_id: STETH_COMPONENT_ID.to_string(),
1325                    updated_attributes: HashMap::from([
1326                        (TOTAL_SHARES_ATTR.to_string(), attribute(U256::from(1u64))),
1327                        (
1328                            PREV_STAKE_BLOCK_NUMBER_ATTR.to_string(),
1329                            attribute(U256::from(1u64) << 32),
1330                        ),
1331                    ]),
1332                    deleted_attributes: Default::default(),
1333                },
1334                &HashMap::new(),
1335                &Balances::default(),
1336            )
1337            .unwrap_err();
1338
1339        let TransitionError::DecodeError(message) = err else {
1340            panic!("expected a decode error, got {err:?}");
1341        };
1342        assert!(message.contains(PREV_STAKE_BLOCK_NUMBER_ATTR), "{message}");
1343        assert_eq!(state, before, "a rejected delta must leave the state untouched");
1344    }
1345
1346    #[test]
1347    fn unwrap_quote_is_bounded_by_wrapper_shares() {
1348        let state = sample_state();
1349        let (max_in, _) = state
1350            .get_limits(Bytes::from(WSTETH_ADDRESS), Bytes::from(STETH_ADDRESS))
1351            .expect("limits");
1352
1353        // At the limit it quotes ...
1354        assert!(state
1355            .get_amount_out(max_in.clone(), &wsteth_token(), &steth_token())
1356            .is_ok());
1357        // ... and one wei past it, it refuses. `get_limits` and `get_amount_out` have to agree
1358        // on the same bound.
1359        let err = state
1360            .get_amount_out(max_in + BigUint::from(1u64), &wsteth_token(), &steth_token())
1361            .unwrap_err();
1362        assert!(matches!(err, SimulationError::RecoverableError(ref m) if m == "UNWRAP_LIMIT"));
1363    }
1364
1365    #[test]
1366    fn eth_to_wsteth_mints_the_submitted_shares() {
1367        let state = sample_state();
1368        let amount_in = BigUint::from(10u64).pow(18);
1369
1370        let result = state
1371            .get_amount_out(amount_in.clone(), &eth_token(), &wsteth_token())
1372            .expect("quote");
1373
1374        // `receive()` mints exactly the shares `submit` returned.
1375        let expected = state
1376            .shares_for_pooled_eth(biguint_to_u256(&amount_in))
1377            .unwrap();
1378        assert_eq!(result.amount, u256_to_biguint(expected));
1379
1380        let new_state = result
1381            .new_state
1382            .as_any()
1383            .downcast_ref::<LidoV4State>()
1384            .unwrap();
1385        // The submitted ETH is buffered, the shares are minted, and they land on the wrapper.
1386        assert_eq!(new_state.buffered_ether, state.buffered_ether + biguint_to_u256(&amount_in));
1387        assert_eq!(new_state.total_shares, state.total_shares + expected);
1388        assert_eq!(new_state.wsteth_shares, state.wsteth_shares + expected);
1389    }
1390
1391    #[test]
1392    fn eth_to_wsteth_is_bounded_by_the_stake_limit() {
1393        let state = sample_state();
1394
1395        let (max_in, max_out) = state
1396            .get_limits(Bytes::from(ETH_ADDRESS), Bytes::from(WSTETH_ADDRESS))
1397            .expect("limits");
1398
1399        assert_eq!(
1400            max_in,
1401            u256_to_biguint(
1402                state
1403                    .staking_state
1404                    .current_limit(state.execution_block_number)
1405            )
1406        );
1407        assert!(max_out > BigUint::ZERO);
1408    }
1409
1410    #[test]
1411    fn eth_to_wsteth_beats_routing_through_steth() {
1412        let state = sample_state();
1413        let amount_in = BigUint::from(10u64).pow(18);
1414
1415        let direct = state
1416            .get_amount_out(amount_in.clone(), &eth_token(), &wsteth_token())
1417            .expect("direct");
1418        // The two-hop route: submit on the stETH component, then wrap on this one.
1419        let submitted = sample_state()
1420            .get_amount_out(amount_in, &eth_token(), &steth_token())
1421            .expect("submit");
1422        let wrapped = state
1423            .get_amount_out(submitted.amount, &steth_token(), &wsteth_token())
1424            .expect("wrap");
1425
1426        assert!(direct.amount >= wrapped.amount, "shortcut must not quote worse");
1427        assert!(direct.gas < submitted.gas + wrapped.gas, "shortcut must be cheaper");
1428    }
1429
1430    /// One component carries three tokens, so a caller can ask for any of six orderings. Only
1431    /// the four the venue performs may quote; the two that would unstake have to report a zero
1432    /// limit and refuse to price or swap, in every method, or a router builds a leg that cannot
1433    /// settle.
1434    #[test]
1435    fn component_serves_only_the_four_directions_the_venue_performs() {
1436        let state = sample_state();
1437        let amount = BigUint::from(10u64).pow(18);
1438
1439        let tradable = [
1440            (eth_token(), steth_token()),
1441            (steth_token(), wsteth_token()),
1442            (wsteth_token(), steth_token()),
1443            (eth_token(), wsteth_token()),
1444        ];
1445        // Unstaking runs through the asynchronous withdrawal queue.
1446        let untradable = [(steth_token(), eth_token()), (wsteth_token(), eth_token())];
1447
1448        for (token_in, token_out) in &tradable {
1449            let pair = format!("{} -> {}", token_in.symbol, token_out.symbol);
1450            let (max_in, max_out) = state
1451                .get_limits(token_in.address.clone(), token_out.address.clone())
1452                .unwrap_or_else(|e| panic!("{pair} limits: {e:?}"));
1453            assert!(max_in > BigUint::ZERO, "{pair} has no input capacity");
1454            assert!(max_out > BigUint::ZERO, "{pair} has no output capacity");
1455            assert!(
1456                state
1457                    .spot_price(token_in, token_out)
1458                    .is_ok(),
1459                "{pair} has no price"
1460            );
1461            assert!(
1462                state
1463                    .get_amount_out(amount.clone(), token_in, token_out)
1464                    .is_ok(),
1465                "{pair} does not quote"
1466            );
1467        }
1468
1469        // A token the component does not hold is a different answer from "no capacity".
1470        let weth = Token::new(&Bytes::from([0xc0u8; 20]), "WETH", 18, 0, &[], Chain::Ethereum, 100);
1471        assert!(
1472            state
1473                .get_limits(weth.address.clone(), steth_token().address.clone())
1474                .is_err(),
1475            "an unknown token reported a limit instead of an error"
1476        );
1477        assert!(state
1478            .spot_price(&weth, &steth_token())
1479            .is_err());
1480        assert!(state
1481            .get_amount_out(amount.clone(), &weth, &steth_token())
1482            .is_err());
1483
1484        for (token_in, token_out) in &untradable {
1485            let pair = format!("{} -> {}", token_in.symbol, token_out.symbol);
1486            assert_eq!(
1487                state
1488                    .get_limits(token_in.address.clone(), token_out.address.clone())
1489                    .unwrap_or_else(|e| panic!("{pair} limits: {e:?}")),
1490                (BigUint::ZERO, BigUint::ZERO),
1491                "{pair} reports capacity it cannot settle"
1492            );
1493            assert!(
1494                state
1495                    .spot_price(token_in, token_out)
1496                    .is_err(),
1497                "{pair} has a price"
1498            );
1499            assert!(
1500                state
1501                    .get_amount_out(amount.clone(), token_in, token_out)
1502                    .is_err(),
1503                "{pair} quotes a swap the venue cannot perform"
1504            );
1505        }
1506    }
1507
1508    /// State read from stETH storage at the Lido v4 migration block 25603297. Pricing
1509    /// `sharesOf(wstETH)` at the share rate has to land on `stETH.balanceOf(wstETH)` from the
1510    /// same block, which pins the v4 pooled-ether formula to the chain.
1511    #[test]
1512    fn share_rate_matches_chain_on_the_v4_storage_layout() {
1513        let state = LidoV4State::new(
1514            25_603_297,
1515            U256::from_str_radix("7526667021904051320418763", 10).unwrap(),
1516            U256::from_str_radix("3721126242498807385407", 10).unwrap(),
1517            U256::from_str_radix("539569870340371095571", 10).unwrap(),
1518            U256::from_str_radix("761440000000000000000000", 10).unwrap(),
1519            U256::from_str_radix("8567227049119653000000000", 10).unwrap(),
1520            U256::ZERO,
1521            sample_staking_state(),
1522            U256::from_str_radix("3628434125893615122886002", 10).unwrap(),
1523        );
1524
1525        let wsteth_backing = state
1526            .pooled_eth_by_shares(state.wsteth_shares)
1527            .unwrap();
1528
1529        assert_eq!(wsteth_backing, U256::from_str_radix("4499621841408863271318368", 10).unwrap());
1530    }
1531
1532    #[test]
1533    fn unsupported_direction_errors() {
1534        let err = sample_state()
1535            .get_amount_out(BigUint::from(10u64).pow(18), &steth_token(), &eth_token())
1536            .unwrap_err();
1537        assert!(matches!(err, SimulationError::FatalError(_)));
1538    }
1539
1540    #[tokio::test]
1541    async fn decoder_seeds_the_execution_block_from_the_header() {
1542        let snapshot = snapshot();
1543        let state = LidoV4State::try_from_with_header(
1544            snapshot,
1545            BlockHeader {
1546                number: 123,
1547                timestamp: 456,
1548                hash: Bytes::new(),
1549                parent_hash: Bytes::new(),
1550                revert: false,
1551                partial_block_index: None,
1552            },
1553            &HashMap::new(),
1554            &HashMap::new(),
1555            &Default::default(),
1556        )
1557        .await
1558        .unwrap();
1559
1560        assert_eq!(state.execution_block_number, 123);
1561    }
1562
1563    /// The stake limit accrues per block, so an idle block still has to move it - that is the
1564    /// whole reason the block cannot come in on a delta.
1565    #[test]
1566    fn apply_block_accrues_the_stake_limit_without_a_delta() {
1567        let mut state = sample_state();
1568        // The fixture starts at `max_stake_limit`, where nothing can accrue. A partly consumed
1569        // limit is the case this guards.
1570        state.staking_state.prev_stake_limit = state.staking_state.max_stake_limit / U256::from(2);
1571        let limit_before = state
1572            .staking_state
1573            .current_limit(state.execution_block_number);
1574
1575        let changed = state.apply_block(&BlockContext::new(state.execution_block_number + 1, 0));
1576
1577        assert!(changed, "an accruing limit must re-emit");
1578        assert!(
1579            state
1580                .staking_state
1581                .current_limit(state.execution_block_number) >
1582                limit_before
1583        );
1584    }
1585
1586    #[test]
1587    fn apply_block_is_idempotent_for_a_repeated_block() {
1588        let mut state = sample_state();
1589        let block = BlockContext::new(state.execution_block_number, 0);
1590
1591        assert!(!state.apply_block(&block));
1592        assert!(!state.apply_block(&block));
1593    }
1594
1595    /// Once the limit sits at `max_stake_limit` it cannot grow further, so later blocks must not
1596    /// keep re-emitting the state to consumers.
1597    #[test]
1598    fn apply_block_does_not_re_emit_once_the_limit_is_saturated() {
1599        let mut state = sample_state();
1600        state.staking_state.prev_stake_limit = state.staking_state.max_stake_limit / U256::from(2);
1601        state.apply_block(&BlockContext::new(state.execution_block_number + 10_000_000, 0));
1602        let saturated = state
1603            .staking_state
1604            .current_limit(state.execution_block_number);
1605        assert_eq!(saturated, state.staking_state.max_stake_limit);
1606
1607        let changed = state.apply_block(&BlockContext::new(state.execution_block_number + 1, 0));
1608
1609        assert!(!changed);
1610    }
1611
1612    fn every_token_pair() -> Vec<(Bytes, Bytes)> {
1613        let tokens = [ETH_ADDRESS, STETH_ADDRESS, WSTETH_ADDRESS];
1614        let mut pairs = Vec::new();
1615        for sell in tokens {
1616            for buy in tokens {
1617                if sell != buy {
1618                    pairs.push((Bytes::from(sell), Bytes::from(buy)));
1619                }
1620            }
1621        }
1622        pairs
1623    }
1624
1625    /// A reported limit has to be a trade the venue performs: quoting at it must succeed and
1626    /// return exactly the reported output.
1627    #[test]
1628    fn every_reported_limit_quotes_at_its_own_size() {
1629        let state = sample_state();
1630        for (sell, buy) in every_token_pair() {
1631            let (max_in, max_out) = state
1632                .get_limits(sell.clone(), buy.clone())
1633                .expect("a pair the component holds");
1634            if max_in == BigUint::ZERO {
1635                assert_eq!(max_out, BigUint::ZERO, "{sell:x} -> {buy:x} pays out of a zero limit");
1636                continue;
1637            }
1638            let token_in = Token::new(&sell, "in", 18, 0, &[], Chain::Ethereum, 100);
1639            let token_out = Token::new(&buy, "out", 18, 0, &[], Chain::Ethereum, 100);
1640            let quoted = state
1641                .get_amount_out(max_in.clone(), &token_in, &token_out)
1642                .unwrap_or_else(|e| panic!("{sell:x} -> {buy:x} limit does not quote: {e:?}"));
1643            assert_eq!(quoted.amount, max_out, "{sell:x} -> {buy:x} limit disagrees with quote");
1644        }
1645    }
1646
1647    /// The share rate is applied in one direction or the other, and the two are inverse up to
1648    /// their rounding, so neither can be applied to a figure already in the other unit.
1649    #[test]
1650    fn shares_and_pooled_ether_round_trip() {
1651        let state = sample_state();
1652        // Each division truncates by under one unit, and the first loss is then scaled
1653        // by the share rate, so a round trip can lose the rate plus one.
1654        let tolerance = state
1655            .pooled_eth_by_shares(U256::ONE)
1656            .expect("rate") +
1657            U256::from(2u8);
1658        for exponent in [15u32, 18, 21, 24] {
1659            let amount = U256::from(10u64).pow(U256::from(exponent));
1660            let back = state
1661                .pooled_eth_by_shares(
1662                    state
1663                        .shares_for_pooled_eth(amount)
1664                        .expect("shares"),
1665                )
1666                .expect("amount");
1667            assert!(back <= amount && amount - back <= tolerance, "amount drifted at 1e{exponent}");
1668
1669            let shares = U256::from(10u64).pow(U256::from(exponent));
1670            let back = state
1671                .shares_for_pooled_eth(
1672                    state
1673                        .pooled_eth_by_shares(shares)
1674                        .expect("amount"),
1675                )
1676                .expect("shares");
1677            assert!(back <= shares && shares - back <= tolerance, "shares drifted at 1e{exponent}");
1678        }
1679    }
1680
1681    /// The decoder requires every name the component carries, so a value the package emits
1682    /// cannot be left unread.
1683    #[tokio::test]
1684    async fn decoder_requires_every_attribute_the_component_carries() {
1685        assert_eq!(snapshot().state.attributes.len(), COMPONENT_ATTRS.len());
1686        for name in COMPONENT_ATTRS {
1687            let mut snapshot = snapshot();
1688            snapshot.state.attributes.remove(name);
1689            let err = try_decode_snapshot_with_defaults::<LidoV4State>(snapshot)
1690                .await
1691                .unwrap_err();
1692            let InvalidSnapshotError::MissingAttribute(missing) = err else {
1693                panic!("{name} removed but the decoder did not report it: {err:?}");
1694            };
1695            assert_eq!(missing, name);
1696        }
1697    }
1698
1699    /// A delta and a fresh snapshot of the same attributes produce identical state.
1700    #[tokio::test]
1701    async fn delta_transition_applies_every_attribute_the_component_carries() {
1702        let base = try_decode_snapshot_with_defaults::<LidoV4State>(snapshot())
1703            .await
1704            .unwrap();
1705        for name in COMPONENT_ATTRS {
1706            let mut state = base.clone();
1707            state
1708                .delta_transition(
1709                    ProtocolStateDelta {
1710                        component_id: STETH_COMPONENT_ID.to_string(),
1711                        updated_attributes: HashMap::from([(
1712                            name.to_string(),
1713                            attribute(U256::from(7u64)),
1714                        )]),
1715                        deleted_attributes: Default::default(),
1716                    },
1717                    &HashMap::new(),
1718                    &Balances::default(),
1719                )
1720                .unwrap_or_else(|e| panic!("{name} was rejected: {e:?}"));
1721            let mut updated = snapshot();
1722            updated
1723                .state
1724                .attributes
1725                .insert(name.to_string(), attribute(U256::from(7u64)));
1726            let expected = try_decode_snapshot_with_defaults::<LidoV4State>(updated)
1727                .await
1728                .unwrap();
1729            assert_eq!(state, expected, "{name} updated the wrong state");
1730        }
1731    }
1732
1733    #[test]
1734    fn delta_transition_ignores_unknown_attributes() {
1735        let mut state = sample_state();
1736        let expected = state.clone();
1737        state
1738            .delta_transition(
1739                ProtocolStateDelta {
1740                    component_id: STETH_COMPONENT_ID.to_string(),
1741                    updated_attributes: HashMap::from([(
1742                        "future_parameter".to_string(),
1743                        Bytes::from(vec![0xff; 64]),
1744                    )]),
1745                    deleted_attributes: Default::default(),
1746                },
1747                &HashMap::new(),
1748                &Balances::default(),
1749            )
1750            .unwrap();
1751        assert_eq!(state, expected);
1752    }
1753
1754    /// The stream decoder puts the chain head in every delta. Those names are not Lido
1755    /// attributes and leave the state alone.
1756    #[test]
1757    fn delta_transition_accepts_the_injected_block_attributes() {
1758        let mut state = sample_state();
1759        state
1760            .delta_transition(
1761                ProtocolStateDelta {
1762                    component_id: STETH_COMPONENT_ID.to_string(),
1763                    updated_attributes: HashMap::from([
1764                        (
1765                            "block_number".to_string(),
1766                            Bytes::from(24_083_114u64.to_be_bytes().to_vec()),
1767                        ),
1768                        (
1769                            "block_timestamp".to_string(),
1770                            Bytes::from(1_700_000_000u64.to_be_bytes().to_vec()),
1771                        ),
1772                    ]),
1773                    deleted_attributes: Default::default(),
1774                },
1775                &HashMap::new(),
1776                &Balances::default(),
1777            )
1778            .expect("the injected names are tolerated");
1779        assert_eq!(state, sample_state());
1780    }
1781}