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