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
22pub 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
30const ETH: &[u8] = Ð_ADDRESS;
32const STETH: &[u8] = &STETH_ADDRESS;
33const WSTETH: &[u8] = &WSTETH_ADDRESS;
34
35pub 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
51const 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 execution_block_number: u64,
71 total_shares: U256,
72 external_shares: U256,
73 buffered_ether: U256,
74 deposited_post_report: U256,
77 cl_validators_balance: U256,
78 cl_pending_balance: U256,
79 staking_state: StakingState,
80 wsteth_shares: U256,
82}
83
84fn 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
93fn 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
111pub(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
124pub(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 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 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 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 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 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 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 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 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 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 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 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 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 (ETH, STETH) => {
429 to_price(self.pooled_eth_by_shares(self.shares_for_pooled_eth(base_unit)?)?)
430 }
431 (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 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 (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 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 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 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 fn sample_wsteth_shares() -> U256 {
704 U256::from_str_radix("2960000000000000000000000", 10).unwrap()
705 }
706
707 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 #[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 #[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 #[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(), ð_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 #[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 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 assert_eq!(
903 unwrapped.wsteth_shares,
904 state.wsteth_shares - biguint_to_u256(&amount_in) + U256::ONE
905 );
906 }
907
908 #[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 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 ð_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(), ð_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), ð_token(), &output)
1054 .is_err());
1055 }
1056 }
1057 }
1058
1059 #[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 let steth_per_eth = state
1087 .spot_price(ð_token(), &steth_token())
1088 .expect("ETH -> stETH price");
1089 let wsteth_per_eth = state
1090 .spot_price(ð_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 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 assert!(state
1116 .get_amount_out(BigUint::from(10u64).pow(18), ð_token(), &steth_token())
1117 .is_err());
1118 assert!(state
1120 .spot_price(ð_token(), &steth_token())
1121 .is_ok());
1122 assert!(state
1123 .spot_price(ð_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 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 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 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), ð_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 #[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 assert!(state
1355 .get_amount_out(max_in.clone(), &wsteth_token(), &steth_token())
1356 .is_ok());
1357 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(), ð_token(), &wsteth_token())
1372 .expect("quote");
1373
1374 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 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(), ð_token(), &wsteth_token())
1417 .expect("direct");
1418 let submitted = sample_state()
1420 .get_amount_out(amount_in, ð_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 #[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 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 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 #[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(), ð_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 #[test]
1566 fn apply_block_accrues_the_stake_limit_without_a_delta() {
1567 let mut state = sample_state();
1568 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 #[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 #[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 #[test]
1650 fn shares_and_pooled_ether_round_trip() {
1651 let state = sample_state();
1652 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 #[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 #[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 #[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}