From ad19cc6c526b929086cd10ab2b1e0aae30bb291c Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 17 Apr 2026 11:20:02 +0800 Subject: [PATCH 1/6] feat: add initial Lido V3 integration --- protocols/substreams/Cargo.toml | 1 + .../substreams/ethereum-lido-v3/Cargo.toml | 19 ++ .../integration_test.tycho.yaml | 29 ++ .../ethereum-lido-v3/rust-toolchain.toml | 4 + .../ethereum-lido-v3/src/constants.rs | 27 ++ .../substreams/ethereum-lido-v3/src/lib.rs | 4 + .../src/modules/1_map_protocol_components.rs | 48 ++++ .../src/modules/2_store_protocol_state.rs | 92 ++++++ .../src/modules/3_map_protocol_changes.rs | 267 ++++++++++++++++++ .../ethereum-lido-v3/src/modules/mod.rs | 8 + .../substreams/ethereum-lido-v3/src/state.rs | 115 ++++++++ .../substreams/ethereum-lido-v3/src/utils.rs | 93 ++++++ .../ethereum-lido-v3/substreams.yaml | 65 +++++ 13 files changed, 772 insertions(+) create mode 100644 protocols/substreams/ethereum-lido-v3/Cargo.toml create mode 100644 protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml create mode 100644 protocols/substreams/ethereum-lido-v3/rust-toolchain.toml create mode 100644 protocols/substreams/ethereum-lido-v3/src/constants.rs create mode 100644 protocols/substreams/ethereum-lido-v3/src/lib.rs create mode 100644 protocols/substreams/ethereum-lido-v3/src/modules/1_map_protocol_components.rs create mode 100644 protocols/substreams/ethereum-lido-v3/src/modules/2_store_protocol_state.rs create mode 100644 protocols/substreams/ethereum-lido-v3/src/modules/3_map_protocol_changes.rs create mode 100644 protocols/substreams/ethereum-lido-v3/src/modules/mod.rs create mode 100644 protocols/substreams/ethereum-lido-v3/src/state.rs create mode 100644 protocols/substreams/ethereum-lido-v3/src/utils.rs create mode 100644 protocols/substreams/ethereum-lido-v3/substreams.yaml diff --git a/protocols/substreams/Cargo.toml b/protocols/substreams/Cargo.toml index 173a3ec693..b8b701783a 100644 --- a/protocols/substreams/Cargo.toml +++ b/protocols/substreams/Cargo.toml @@ -19,6 +19,7 @@ members = [ "ethereum-ekubo-v2", "ethereum-maverick-v2", "ethereum-lido", + "ethereum-lido-v3", "ethereum-balancer-v3", "ethereum-fluid", "base-aerodrome-slipstreams", diff --git a/protocols/substreams/ethereum-lido-v3/Cargo.toml b/protocols/substreams/ethereum-lido-v3/Cargo.toml new file mode 100644 index 0000000000..7b5a105717 --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ethereum-lido-v3" +version = "0.1.0" +edition = "2021" + +[lib] +name = "ethereum_lido_v3" +crate-type = ["cdylib"] + +[dependencies] +substreams = "0.5.22" +substreams-ethereum = "0.9.9" +tycho-substreams = "0.8.0" +anyhow = "1.0.95" +hex = "0.4" +itertools = "0.10.5" +num-bigint = "0.4.6" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml b/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml new file mode 100644 index 0000000000..2610f72eed --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml @@ -0,0 +1,29 @@ +substreams_yaml_path: ./substreams.yaml +protocol_system: "lido_v3" +module_name: "map_protocol_changes" +protocol_type_names: + - "stETH" + - "wstETH" +skip_balance_check: true # Lido liquidity is indexed from internal accounting, not direct component balances. + +tests: + - name: test_lido_v3_component_creation + start_block: 24083113 + stop_block: 24083220 + expected_components: + - id: "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" + tokens: + - "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" + - "0x0000000000000000000000000000000000000000" + static_attributes: { } + creation_tx: "0xd377156654a0d6f646c9eea54e893352f98c26e8ff14911c4df8127578ff3b97" + skip_simulation: true # tycho-simulation does not have a Lido V3 native decoder in this repo yet. + skip_execution: true # tycho-execution does not have a Lido V3 adapter in this repo yet. + - id: "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" + tokens: + - "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" + - "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" + static_attributes: { } + creation_tx: "0xd377156654a0d6f646c9eea54e893352f98c26e8ff14911c4df8127578ff3b97" + skip_simulation: true # tycho-simulation does not have a Lido V3 native decoder in this repo yet. + skip_execution: true # tycho-execution does not have a Lido V3 adapter in this repo yet. diff --git a/protocols/substreams/ethereum-lido-v3/rust-toolchain.toml b/protocols/substreams/ethereum-lido-v3/rust-toolchain.toml new file mode 100644 index 0000000000..15b4897dea --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.83.0" +components = [ "rustfmt" ] +targets = [ "wasm32-unknown-unknown" ] diff --git a/protocols/substreams/ethereum-lido-v3/src/constants.rs b/protocols/substreams/ethereum-lido-v3/src/constants.rs new file mode 100644 index 0000000000..e11fa672bd --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/src/constants.rs @@ -0,0 +1,27 @@ +use substreams::hex; + +pub const STETH_COMPONENT_ID: &str = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"; +pub const WSTETH_COMPONENT_ID: &str = "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"; + +pub const STETH_ADDRESS: [u8; 20] = hex!("ae7ab96520de3a18e5e111b5eaab095312d7fe84"); +pub const WSTETH_ADDRESS: [u8; 20] = hex!("7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0"); +pub const ETH_ADDRESS: [u8; 20] = hex!("0000000000000000000000000000000000000000"); + +pub const TOTAL_AND_EXTERNAL_SHARES_POSITION: [u8; 32] = + hex!("6038150aecaa250d524370a0fdcdec13f2690e0723eaf277f41d7cae26b359e6"); +pub const BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_POSITION: [u8; 32] = + hex!("a84c096ee27e195f25d7b6c7c2a03229e49f1a2a5087e57ce7d7127707942fe3"); +pub const CL_BALANCE_AND_CL_VALIDATORS_POSITION: [u8; 32] = + hex!("c36804a03ec742b57b141e4e5d8d3bd1ddb08451fd0f9983af8aaab357a78e2f"); +pub const STAKING_STATE_POSITION: [u8; 32] = + hex!("a3678de4a579be090bed1177e0a24f77cc29d181ac22fd7688aca344d8938015"); + +pub const TOTAL_SHARES_ATTR: &str = "total_shares"; +pub const EXTERNAL_SHARES_ATTR: &str = "external_shares"; +pub const BUFFERED_ETHER_ATTR: &str = "buffered_ether"; +pub const DEPOSITED_VALIDATORS_ATTR: &str = "deposited_validators"; +pub const CL_BALANCE_ATTR: &str = "cl_balance"; +pub const CL_VALIDATORS_ATTR: &str = "cl_validators"; +pub const STAKING_STATE_ATTR: &str = "staking_state"; +pub const INTERNAL_ETHER_ATTR: &str = "internal_ether"; +pub const INTERNAL_SHARES_ATTR: &str = "internal_shares"; diff --git a/protocols/substreams/ethereum-lido-v3/src/lib.rs b/protocols/substreams/ethereum-lido-v3/src/lib.rs new file mode 100644 index 0000000000..20cf941ffc --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/src/lib.rs @@ -0,0 +1,4 @@ +mod constants; +mod modules; +mod state; +mod utils; diff --git a/protocols/substreams/ethereum-lido-v3/src/modules/1_map_protocol_components.rs b/protocols/substreams/ethereum-lido-v3/src/modules/1_map_protocol_components.rs new file mode 100644 index 0000000000..47789428c3 --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/src/modules/1_map_protocol_components.rs @@ -0,0 +1,48 @@ +use anyhow::{anyhow, Result}; +use substreams_ethereum::pb::eth; +use tycho_substreams::{ + models::{ImplementationType, ProtocolComponent}, + prelude::{BlockTransactionProtocolComponents, TransactionProtocolComponents}, +}; + +use crate::{ + constants::{ + ETH_ADDRESS, STETH_ADDRESS, STETH_COMPONENT_ID, WSTETH_ADDRESS, WSTETH_COMPONENT_ID, + }, + state::InitialState, +}; + +#[substreams::handlers::map] +pub fn map_protocol_components( + params: String, + block: eth::v2::Block, +) -> Result { + let initial_state = InitialState::parse(¶ms)?; + + if block.number != initial_state.start_block { + return Ok(BlockTransactionProtocolComponents { tx_components: vec![] }); + } + + let tx = block + .transactions() + .next() + .ok_or_else(|| anyhow!("Activation block has no transactions"))?; + + Ok(BlockTransactionProtocolComponents { + tx_components: vec![TransactionProtocolComponents { + tx: Some(tx.into()), + components: create_components(), + }], + }) +} + +fn create_components() -> Vec { + vec![ + ProtocolComponent::new(STETH_COMPONENT_ID) + .with_tokens(&[STETH_ADDRESS, ETH_ADDRESS]) + .as_swap_type("stETH", ImplementationType::Custom), + ProtocolComponent::new(WSTETH_COMPONENT_ID) + .with_tokens(&[STETH_ADDRESS, WSTETH_ADDRESS]) + .as_swap_type("wstETH", ImplementationType::Custom), + ] +} diff --git a/protocols/substreams/ethereum-lido-v3/src/modules/2_store_protocol_state.rs b/protocols/substreams/ethereum-lido-v3/src/modules/2_store_protocol_state.rs new file mode 100644 index 0000000000..c02519140c --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/src/modules/2_store_protocol_state.rs @@ -0,0 +1,92 @@ +use anyhow::Result; +use substreams::{ + prelude::StoreSetBigInt, + scalar::BigInt, + store::{StoreNew, StoreSet}, +}; +use substreams_ethereum::pb::eth; + +use crate::{ + constants::{ + BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_POSITION, BUFFERED_ETHER_ATTR, + CL_BALANCE_AND_CL_VALIDATORS_POSITION, CL_BALANCE_ATTR, CL_VALIDATORS_ATTR, + DEPOSITED_VALIDATORS_ATTR, EXTERNAL_SHARES_ATTR, STAKING_STATE_ATTR, + STAKING_STATE_POSITION, STETH_ADDRESS, STETH_COMPONENT_ID, + TOTAL_AND_EXTERNAL_SHARES_POSITION, TOTAL_SHARES_ATTR, + }, + state::{InitialState, LidoProtocolState}, + utils::decode_packed_uint128_pair, +}; + +#[substreams::handlers::store] +pub fn store_protocol_state(params: String, block: eth::v2::Block, store: StoreSetBigInt) { + store_protocol_state_inner(¶ms, &block, &store).expect("Failed to store Lido V3 state"); +} + +fn store_protocol_state_inner( + params: &str, + block: ð::v2::Block, + store: &StoreSetBigInt, +) -> Result<()> { + let initial_state = InitialState::parse(params)?; + + if block.number == initial_state.start_block { + let initial_state = LidoProtocolState::from_initial(&initial_state)?; + set_attr(0, TOTAL_SHARES_ATTR, &initial_state.total_shares, store); + set_attr(0, EXTERNAL_SHARES_ATTR, &initial_state.external_shares, store); + set_attr(0, BUFFERED_ETHER_ATTR, &initial_state.buffered_ether, store); + set_attr(0, DEPOSITED_VALIDATORS_ATTR, &initial_state.deposited_validators, store); + set_attr(0, CL_BALANCE_ATTR, &initial_state.cl_balance, store); + set_attr(0, CL_VALIDATORS_ATTR, &initial_state.cl_validators, store); + set_attr(0, STAKING_STATE_ATTR, &initial_state.staking_state, store); + } + + for tx in block.transactions() { + for call in tx + .calls + .iter() + .filter(|call| !call.state_reverted) + { + for storage_change in call + .storage_changes + .iter() + .filter(|change| change.address == STETH_ADDRESS) + { + if storage_change.key == TOTAL_AND_EXTERNAL_SHARES_POSITION { + let (total_shares, external_shares) = + decode_packed_uint128_pair(&storage_change.new_value); + set_attr(call.begin_ordinal, TOTAL_SHARES_ATTR, &total_shares, store); + set_attr(call.begin_ordinal, EXTERNAL_SHARES_ATTR, &external_shares, store); + } else if storage_change.key == BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_POSITION { + let (buffered_ether, deposited_validators) = + decode_packed_uint128_pair(&storage_change.new_value); + set_attr(call.begin_ordinal, BUFFERED_ETHER_ATTR, &buffered_ether, store); + set_attr( + call.begin_ordinal, + DEPOSITED_VALIDATORS_ATTR, + &deposited_validators, + store, + ); + } else if storage_change.key == CL_BALANCE_AND_CL_VALIDATORS_POSITION { + let (cl_balance, cl_validators) = + decode_packed_uint128_pair(&storage_change.new_value); + set_attr(call.begin_ordinal, CL_BALANCE_ATTR, &cl_balance, store); + set_attr(call.begin_ordinal, CL_VALIDATORS_ATTR, &cl_validators, store); + } else if storage_change.key == STAKING_STATE_POSITION { + set_attr( + call.begin_ordinal, + STAKING_STATE_ATTR, + &BigInt::from_unsigned_bytes_be(&storage_change.new_value), + store, + ); + } + } + } + } + + Ok(()) +} + +fn set_attr(ordinal: u64, attr: &str, value: &BigInt, store: &StoreSetBigInt) { + store.set(ordinal, format!("{STETH_COMPONENT_ID}:{attr}"), value); +} diff --git a/protocols/substreams/ethereum-lido-v3/src/modules/3_map_protocol_changes.rs b/protocols/substreams/ethereum-lido-v3/src/modules/3_map_protocol_changes.rs new file mode 100644 index 0000000000..e21d7c122e --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/src/modules/3_map_protocol_changes.rs @@ -0,0 +1,267 @@ +use anyhow::{anyhow, Result}; +use itertools::Itertools; +use std::collections::HashMap; +use substreams::{ + pb::substreams::StoreDeltas, + scalar::BigInt, + store::{StoreGet, StoreGetBigInt}, +}; +use substreams_ethereum::pb::eth; +use tycho_substreams::{ + models::{BalanceChange, BlockChanges, ChangeType, EntityChanges, TransactionChangesBuilder}, + prelude::BlockTransactionProtocolComponents, +}; + +use crate::{ + constants::{ + BUFFERED_ETHER_ATTR, CL_BALANCE_ATTR, CL_VALIDATORS_ATTR, DEPOSITED_VALIDATORS_ATTR, + ETH_ADDRESS, EXTERNAL_SHARES_ATTR, STAKING_STATE_ATTR, STETH_ADDRESS, STETH_COMPONENT_ID, + TOTAL_SHARES_ATTR, WSTETH_COMPONENT_ID, + }, + state::{InitialState, LidoProtocolState}, + utils::{attribute_with_bigint, bigint_from_store_value}, +}; + +#[substreams::handlers::map] +pub fn map_protocol_changes( + params: String, + block: eth::v2::Block, + protocol_components: BlockTransactionProtocolComponents, + storage_deltas: StoreDeltas, + storage_store: StoreGetBigInt, +) -> Result { + let initial_state = InitialState::parse(¶ms)?; + let mut transaction_changes: HashMap = HashMap::new(); + + if !protocol_components + .tx_components + .is_empty() + { + initialize_protocol_components( + &initial_state, + protocol_components, + &mut transaction_changes, + )?; + } else { + handle_state_updates(&block, &storage_deltas, &storage_store, &mut transaction_changes)?; + } + + Ok(BlockChanges { + block: Some((&block).into()), + changes: transaction_changes + .drain() + .sorted_unstable_by_key(|(index, _)| *index) + .filter_map(|(_, builder)| builder.build()) + .collect(), + storage_changes: vec![], + }) +} + +fn initialize_protocol_components( + initial_state: &InitialState, + protocol_components: BlockTransactionProtocolComponents, + transaction_changes: &mut HashMap, +) -> Result<()> { + let state = LidoProtocolState::from_initial(initial_state)?; + + let tx_component = protocol_components + .tx_components + .into_iter() + .next() + .ok_or_else(|| anyhow!("Missing activation transaction component"))?; + let tx = tx_component + .tx + .as_ref() + .ok_or_else(|| anyhow!("Activation transaction missing"))?; + + let builder = transaction_changes + .entry(tx.index) + .or_insert_with(|| TransactionChangesBuilder::new(tx)); + + for component in tx_component.components { + builder.add_protocol_component(&component); + } + + builder.add_entity_change(&EntityChanges { + component_id: STETH_COMPONENT_ID.to_string(), + attributes: state.steth_creation_attributes(), + }); + builder.add_entity_change(&EntityChanges { + component_id: WSTETH_COMPONENT_ID.to_string(), + attributes: state.shared_creation_attributes(), + }); + + let internal_ether = state + .internal_ether() + .to_signed_bytes_be(); + builder.add_balance_change(&BalanceChange { + token: ETH_ADDRESS.to_vec(), + balance: internal_ether.clone(), + component_id: STETH_COMPONENT_ID.as_bytes().to_vec(), + }); + builder.add_balance_change(&BalanceChange { + token: STETH_ADDRESS.to_vec(), + balance: internal_ether, + component_id: WSTETH_COMPONENT_ID.as_bytes().to_vec(), + }); + + Ok(()) +} + +fn handle_state_updates( + block: ð::v2::Block, + storage_deltas: &StoreDeltas, + storage_store: &StoreGetBigInt, + transaction_changes: &mut HashMap, +) -> Result<()> { + let mut sorted_deltas = storage_deltas + .deltas + .iter() + .filter(|delta| { + delta + .key + .starts_with(&format!("{STETH_COMPONENT_ID}:")) + }) + .collect::>(); + sorted_deltas.sort_by_key(|delta| delta.ordinal); + + if sorted_deltas.is_empty() { + return Ok(()); + } + + let mut current_state = LidoProtocolState { + total_shares: get_initial_value_for_block( + &sorted_deltas, + storage_store, + TOTAL_SHARES_ATTR, + )?, + external_shares: get_initial_value_for_block( + &sorted_deltas, + storage_store, + EXTERNAL_SHARES_ATTR, + )?, + buffered_ether: get_initial_value_for_block( + &sorted_deltas, + storage_store, + BUFFERED_ETHER_ATTR, + )?, + deposited_validators: get_initial_value_for_block( + &sorted_deltas, + storage_store, + DEPOSITED_VALIDATORS_ATTR, + )?, + cl_balance: get_initial_value_for_block(&sorted_deltas, storage_store, CL_BALANCE_ATTR)?, + cl_validators: get_initial_value_for_block( + &sorted_deltas, + storage_store, + CL_VALIDATORS_ATTR, + )?, + staking_state: get_initial_value_for_block( + &sorted_deltas, + storage_store, + STAKING_STATE_ATTR, + )?, + }; + + for delta in &sorted_deltas { + let attr_name = delta + .key + .split(':') + .next_back() + .ok_or_else(|| anyhow!("Unexpected store key format: {}", delta.key))?; + let attr_value = bigint_from_store_value(&delta.new_value)?; + current_state.apply_attribute(attr_name, attr_value.clone())?; + + let tx = transaction_for_ordinal(block, delta.ordinal) + .ok_or_else(|| anyhow!("No transaction found for ordinal {}", delta.ordinal))?; + let builder = transaction_changes + .entry(tx.index as u64) + .or_insert_with(|| TransactionChangesBuilder::new(&(tx.into()))); + + if attr_name == STAKING_STATE_ATTR { + builder.add_entity_change(&EntityChanges { + component_id: STETH_COMPONENT_ID.to_string(), + attributes: vec![attribute_with_bigint( + STAKING_STATE_ATTR, + &attr_value, + ChangeType::Update, + )], + }); + } else { + let attribute = attribute_with_bigint(attr_name, &attr_value, ChangeType::Update); + builder.add_entity_change(&EntityChanges { + component_id: STETH_COMPONENT_ID.to_string(), + attributes: vec![attribute.clone()], + }); + builder.add_entity_change(&EntityChanges { + component_id: WSTETH_COMPONENT_ID.to_string(), + attributes: vec![attribute], + }); + } + + let is_last_delta_for_ordinal = sorted_deltas + .iter() + .rfind(|candidate| candidate.ordinal == delta.ordinal) + .map(|last_delta| std::ptr::eq(*last_delta, *delta)) + .unwrap_or(false); + + if !is_last_delta_for_ordinal { + continue; + } + + let shared_update_attributes = current_state.shared_update_attributes(); + builder.add_entity_change(&EntityChanges { + component_id: STETH_COMPONENT_ID.to_string(), + attributes: shared_update_attributes.clone(), + }); + builder.add_entity_change(&EntityChanges { + component_id: WSTETH_COMPONENT_ID.to_string(), + attributes: shared_update_attributes, + }); + + let internal_ether = current_state + .internal_ether() + .to_signed_bytes_be(); + builder.add_balance_change(&BalanceChange { + token: ETH_ADDRESS.to_vec(), + balance: internal_ether.clone(), + component_id: STETH_COMPONENT_ID.as_bytes().to_vec(), + }); + builder.add_balance_change(&BalanceChange { + token: STETH_ADDRESS.to_vec(), + balance: internal_ether, + component_id: WSTETH_COMPONENT_ID.as_bytes().to_vec(), + }); + } + + Ok(()) +} + +fn get_initial_value_for_block( + deltas: &[&substreams::pb::substreams::StoreDelta], + storage_store: &StoreGetBigInt, + suffix: &str, +) -> Result { + if let Some(first_delta) = deltas + .iter() + .filter(|delta| delta.key.ends_with(suffix)) + .min_by_key(|delta| delta.ordinal) + { + return bigint_from_store_value(&first_delta.old_value); + } + + Ok(storage_store + .get_last(format!("{STETH_COMPONENT_ID}:{suffix}")) + .unwrap_or_else(|| BigInt::from(0))) +} + +fn transaction_for_ordinal( + block: ð::v2::Block, + ordinal: u64, +) -> Option<ð::v2::TransactionTrace> { + block.transactions().find(|tx| { + tx.calls + .iter() + .any(|call| call.begin_ordinal <= ordinal && call.end_ordinal >= ordinal) + }) +} diff --git a/protocols/substreams/ethereum-lido-v3/src/modules/mod.rs b/protocols/substreams/ethereum-lido-v3/src/modules/mod.rs new file mode 100644 index 0000000000..2edba7fb44 --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/src/modules/mod.rs @@ -0,0 +1,8 @@ +#[path = "1_map_protocol_components.rs"] +mod map_protocol_components; + +#[path = "2_store_protocol_state.rs"] +mod store_protocol_state; + +#[path = "3_map_protocol_changes.rs"] +mod map_protocol_changes; diff --git a/protocols/substreams/ethereum-lido-v3/src/state.rs b/protocols/substreams/ethereum-lido-v3/src/state.rs new file mode 100644 index 0000000000..b174ad5f55 --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/src/state.rs @@ -0,0 +1,115 @@ +use anyhow::{anyhow, Result}; +use serde::Deserialize; +use substreams::scalar::BigInt; +use tycho_substreams::models::{Attribute, ChangeType}; + +use crate::{ + constants::{ + BUFFERED_ETHER_ATTR, CL_BALANCE_ATTR, CL_VALIDATORS_ATTR, DEPOSITED_VALIDATORS_ATTR, + EXTERNAL_SHARES_ATTR, INTERNAL_ETHER_ATTR, INTERNAL_SHARES_ATTR, STAKING_STATE_ATTR, + TOTAL_SHARES_ATTR, + }, + utils::{attribute_with_bigint, bigint_from_hex}, +}; + +#[derive(Clone, Debug, Default)] +pub struct LidoProtocolState { + pub total_shares: BigInt, + pub external_shares: BigInt, + pub buffered_ether: BigInt, + pub deposited_validators: BigInt, + pub cl_balance: BigInt, + pub cl_validators: BigInt, + pub staking_state: BigInt, +} + +impl LidoProtocolState { + pub fn from_initial(initial_state: &InitialState) -> Result { + Ok(Self { + total_shares: bigint_from_hex(&initial_state.total_shares)?, + external_shares: bigint_from_hex(&initial_state.external_shares)?, + buffered_ether: bigint_from_hex(&initial_state.buffered_ether)?, + deposited_validators: bigint_from_hex(&initial_state.deposited_validators)?, + cl_balance: bigint_from_hex(&initial_state.cl_balance)?, + cl_validators: bigint_from_hex(&initial_state.cl_validators)?, + staking_state: bigint_from_hex(&initial_state.staking_state)?, + }) + } + + pub fn apply_attribute(&mut self, name: &str, value: BigInt) -> Result<()> { + match name { + TOTAL_SHARES_ATTR => self.total_shares = value, + EXTERNAL_SHARES_ATTR => self.external_shares = value, + BUFFERED_ETHER_ATTR => self.buffered_ether = value, + DEPOSITED_VALIDATORS_ATTR => self.deposited_validators = value, + CL_BALANCE_ATTR => self.cl_balance = value, + CL_VALIDATORS_ATTR => self.cl_validators = value, + STAKING_STATE_ATTR => self.staking_state = value, + _ => return Err(anyhow!("Unknown Lido V3 attribute: {name}")), + } + + Ok(()) + } + + pub fn internal_ether(&self) -> BigInt { + let deposit_size = num_bigint::BigInt::parse_bytes(b"32000000000000000000", 10) + .expect("Failed to parse Lido deposit size"); + let transient_ether = + (&self.deposited_validators - &self.cl_validators) * BigInt::from(deposit_size); + &self.buffered_ether + &self.cl_balance + transient_ether + } + + pub fn internal_shares(&self) -> BigInt { + &self.total_shares - &self.external_shares + } + + pub fn shared_creation_attributes(&self) -> Vec { + self.shared_attributes(ChangeType::Creation) + } + + pub fn shared_update_attributes(&self) -> Vec { + self.shared_attributes(ChangeType::Update) + } + + pub fn steth_creation_attributes(&self) -> Vec { + let mut attributes = self.shared_creation_attributes(); + attributes.push(attribute_with_bigint( + STAKING_STATE_ATTR, + &self.staking_state, + ChangeType::Creation, + )); + attributes + } + + fn shared_attributes(&self, change: ChangeType) -> Vec { + vec![ + attribute_with_bigint(TOTAL_SHARES_ATTR, &self.total_shares, change), + attribute_with_bigint(EXTERNAL_SHARES_ATTR, &self.external_shares, change), + attribute_with_bigint(BUFFERED_ETHER_ATTR, &self.buffered_ether, change), + attribute_with_bigint(DEPOSITED_VALIDATORS_ATTR, &self.deposited_validators, change), + attribute_with_bigint(CL_BALANCE_ATTR, &self.cl_balance, change), + attribute_with_bigint(CL_VALIDATORS_ATTR, &self.cl_validators, change), + attribute_with_bigint(INTERNAL_ETHER_ATTR, &self.internal_ether(), change), + attribute_with_bigint(INTERNAL_SHARES_ATTR, &self.internal_shares(), change), + ] + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct InitialState { + pub start_block: u64, + pub total_shares: String, + pub external_shares: String, + pub buffered_ether: String, + pub deposited_validators: String, + pub cl_balance: String, + pub cl_validators: String, + pub staking_state: String, +} + +impl InitialState { + pub fn parse(params: &str) -> Result { + serde_json::from_str(params) + .map_err(|e| anyhow!("Failed to parse Lido V3 initial state: {e}")) + } +} diff --git a/protocols/substreams/ethereum-lido-v3/src/utils.rs b/protocols/substreams/ethereum-lido-v3/src/utils.rs new file mode 100644 index 0000000000..4415082ecb --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/src/utils.rs @@ -0,0 +1,93 @@ +use anyhow::{anyhow, Result}; +use substreams::scalar::BigInt; +use tycho_substreams::models::{Attribute, ChangeType}; + +pub fn attribute_with_bigint(name: &str, value: &BigInt, change: ChangeType) -> Attribute { + Attribute { name: name.to_string(), value: value.to_signed_bytes_be(), change: change.into() } +} + +pub fn bigint_from_hex(value: &str) -> Result { + let value = value + .strip_prefix("0x") + .unwrap_or(value); + let bytes = hex::decode(value).map_err(|e| anyhow!("Failed to decode hex value: {e}"))?; + Ok(BigInt::from_unsigned_bytes_be(&bytes)) +} + +pub fn bigint_from_store_value(value: &[u8]) -> Result { + if value.is_empty() { + return Ok(BigInt::from(0)); + } + + let value_str = + std::str::from_utf8(value).map_err(|e| anyhow!("Invalid UTF-8 store value: {e}"))?; + let parsed = num_bigint::BigInt::parse_bytes(value_str.as_bytes(), 10) + .ok_or_else(|| anyhow!("Failed to parse decimal store value: {value_str}"))?; + + Ok(BigInt::from(parsed)) +} + +pub fn decode_packed_uint128_pair(raw: &[u8]) -> (BigInt, BigInt) { + let low = read_bytes(raw, 0, 16); + let high = read_bytes(raw, 16, 16); + (BigInt::from_unsigned_bytes_be(low), BigInt::from_unsigned_bytes_be(high)) +} + +pub fn read_bytes(buf: &[u8], offset: usize, number_of_bytes: usize) -> &[u8] { + let buf_length = buf.len(); + if buf_length < number_of_bytes { + panic!( + "attempting to read {number_of_bytes} bytes in buffer size {buf_size}", + buf_size = buf.len() + ) + } + + if offset > (buf_length - 1) { + panic!("offset {offset} exceeds buffer size {buf_size}", buf_size = buf.len()) + } + + let end = buf_length - 1 - offset; + let start = (end + 1) + .checked_sub(number_of_bytes) + .unwrap_or_else(|| { + panic!( + "number of bytes {number_of_bytes} with offset {offset} exceeds buffer size \ +{buf_size}", + buf_size = buf.len() + ) + }); + + &buf[start..=end] +} + +#[cfg(test)] +mod tests { + use super::{decode_packed_uint128_pair, read_bytes}; + use substreams::hex; + + #[test] + fn read_low_and_high_uint128_halves() { + let raw = hex!("00000000000000000000000000065004000000000000002303b296dd9f3631db"); + let (low, high) = decode_packed_uint128_pair(&raw); + + assert_eq!( + low, + substreams::scalar::BigInt::from_unsigned_bytes_be(&hex!( + "000000000000002303b296dd9f3631db" + )) + ); + assert_eq!( + high, + substreams::scalar::BigInt::from_unsigned_bytes_be(&hex!( + "00000000000000000000000000065004" + )) + ); + } + + #[test] + fn read_bytes_from_low_side() { + let raw = hex!("aabbccdd"); + assert_eq!(read_bytes(&raw, 0, 2), hex!("ccdd")); + assert_eq!(read_bytes(&raw, 2, 2), hex!("aabb")); + } +} diff --git a/protocols/substreams/ethereum-lido-v3/substreams.yaml b/protocols/substreams/ethereum-lido-v3/substreams.yaml new file mode 100644 index 0000000000..745d5982b7 --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/substreams.yaml @@ -0,0 +1,65 @@ +specVersion: v0.1.0 +package: + name: "ethereum_lido_v3" + version: v0.1.0 + +protobuf: + files: + - tycho/protocols/adapter-integration/evm/v1/common.proto + - tycho/protocols/adapter-integration/evm/v1/utils.proto + importPaths: + - ../../proto + +binaries: + default: + type: wasm/rust-v1 + file: ../target/wasm32-unknown-unknown/release/ethereum_lido_v3.wasm + +network: mainnet + +params: + map_protocol_components: &initial_state | + { + "start_block": 24083113, + "total_shares": "0x00000000000000000000000000000000000000000005f18d02edc955cbcfc9b0", + "external_shares": "0x0000000000000000000000000000000000000000000000000000000000000000", + "buffered_ether": "0x00000000000000000000000000000000000000000000002303b296dd9f3631db", + "deposited_validators": "0x0000000000000000000000000000000000000000000000000000000000065004", + "cl_balance": "0x000000000000000000000000000000000000000000073f77f7680e9a096c4600", + "cl_validators": "0x0000000000000000000000000000000000000000000000000000000000064d49", + "staking_state": "0x00001fc3842bd1f071c000000000190000001fc37ee5c9f3db1a0000016f7a9f" + } + store_protocol_state: *initial_state + map_protocol_changes: *initial_state + +modules: + - name: map_protocol_components + kind: map + initialBlock: &start_block 24083113 + inputs: + - params: string + - source: sf.ethereum.type.v2.Block + output: + type: proto:tycho.evm.v1.BlockTransactionProtocolComponents + + - name: store_protocol_state + kind: store + initialBlock: *start_block + updatePolicy: set + valueType: bigint + inputs: + - params: string + - source: sf.ethereum.type.v2.Block + + - name: map_protocol_changes + kind: map + initialBlock: *start_block + inputs: + - params: string + - source: sf.ethereum.type.v2.Block + - map: map_protocol_components + - store: store_protocol_state + mode: deltas + - store: store_protocol_state + output: + type: proto:tycho.evm.v1.BlockChanges From 7bc7b5652d5785dca58cafa447f8311f6d31ec42 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 21 Apr 2026 10:42:16 +0800 Subject: [PATCH 2/6] Refactor ethereum-lido-v3 raw slot indexing --- .../integration_test.tycho.yaml | 9 +- .../scripts/compute_initial_state.sh | 78 +++++ .../ethereum-lido-v3/src/constants.rs | 13 +- .../ethereum-lido-v3/src/modules.rs | 192 +++++++++++++ .../src/modules/1_map_protocol_components.rs | 48 ---- .../src/modules/2_store_protocol_state.rs | 92 ------ .../src/modules/3_map_protocol_changes.rs | 267 ------------------ .../ethereum-lido-v3/src/modules/mod.rs | 8 - .../substreams/ethereum-lido-v3/src/state.rs | 134 +++------ .../substreams/ethereum-lido-v3/src/utils.rs | 83 +----- .../ethereum-lido-v3/substreams.yaml | 24 +- 11 files changed, 334 insertions(+), 614 deletions(-) create mode 100755 protocols/substreams/ethereum-lido-v3/scripts/compute_initial_state.sh create mode 100644 protocols/substreams/ethereum-lido-v3/src/modules.rs delete mode 100644 protocols/substreams/ethereum-lido-v3/src/modules/1_map_protocol_components.rs delete mode 100644 protocols/substreams/ethereum-lido-v3/src/modules/2_store_protocol_state.rs delete mode 100644 protocols/substreams/ethereum-lido-v3/src/modules/3_map_protocol_changes.rs delete mode 100644 protocols/substreams/ethereum-lido-v3/src/modules/mod.rs diff --git a/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml b/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml index 2610f72eed..18735e62f0 100644 --- a/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml +++ b/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml @@ -2,8 +2,7 @@ substreams_yaml_path: ./substreams.yaml protocol_system: "lido_v3" module_name: "map_protocol_changes" protocol_type_names: - - "stETH" - - "wstETH" + - "lido_v3_pool" skip_balance_check: true # Lido liquidity is indexed from internal accounting, not direct component balances. tests: @@ -15,7 +14,8 @@ tests: tokens: - "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" - "0x0000000000000000000000000000000000000000" - static_attributes: { } + static_attributes: + token_to_track_total_pooled_eth: "0x0000000000000000000000000000000000000000" creation_tx: "0xd377156654a0d6f646c9eea54e893352f98c26e8ff14911c4df8127578ff3b97" skip_simulation: true # tycho-simulation does not have a Lido V3 native decoder in this repo yet. skip_execution: true # tycho-execution does not have a Lido V3 adapter in this repo yet. @@ -23,7 +23,8 @@ tests: tokens: - "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" - "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" - static_attributes: { } + static_attributes: + token_to_track_total_pooled_eth: "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" creation_tx: "0xd377156654a0d6f646c9eea54e893352f98c26e8ff14911c4df8127578ff3b97" skip_simulation: true # tycho-simulation does not have a Lido V3 native decoder in this repo yet. skip_execution: true # tycho-execution does not have a Lido V3 adapter in this repo yet. diff --git a/protocols/substreams/ethereum-lido-v3/scripts/compute_initial_state.sh b/protocols/substreams/ethereum-lido-v3/scripts/compute_initial_state.sh new file mode 100755 index 0000000000..679d21f2c0 --- /dev/null +++ b/protocols/substreams/ethereum-lido-v3/scripts/compute_initial_state.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Script to compute the raw Lido V3 initial state used by substreams.yaml params. +# +# It reads the four tracked stETH proxy storage slots at a given block and prints +# the JSON object expected by the current substreams configuration. +# +# Usage: +# ./scripts/compute_initial_state.sh [block_number] +# LIDO_V3_RPC_URL= ./scripts/compute_initial_state.sh [block_number] +# RPC_URL= ./scripts/compute_initial_state.sh [block_number] +# +# The script tries several endpoint environment variables in order and falls back +# to a public Ethereum RPC endpoint if needed. + +set -euo pipefail + +BLOCK_NUMBER=${1:-24083113} + +if ! command -v cast >/dev/null 2>&1; then + echo "Error: 'cast' is required but was not found in PATH." >&2 + exit 1 +fi + +STETH_PROXY="0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" + +TOTAL_AND_EXTERNAL_SHARES_SLOT="0x6038150aecaa250d524370a0fdcdec13f2690e0723eaf277f41d7cae26b359e6" +BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_SLOT="0xa84c096ee27e195f25d7b6c7c2a03229e49f1a2a5087e57ce7d7127707942fe3" +CL_BALANCE_AND_CL_VALIDATORS_SLOT="0xc36804a03ec742b57b141e4e5d8d3bd1ddb08451fd0f9983af8aaab357a78e2f" +STAKING_STATE_SLOT="0xa3678de4a579be090bed1177e0a24f77cc29d181ac22fd7688aca344d8938015" + +resolve_rpc_url() { + local candidates=() + + if [ -n "${RPC_URL:-}" ]; then + candidates+=("$RPC_URL") + fi + if [ -n "${ETH_RPC_URL:-}" ]; then + candidates+=("$ETH_RPC_URL") + fi + candidates+=("https://ethereum-rpc.publicnode.com") + + local candidate + for candidate in "${candidates[@]}"; do + if cast block "$BLOCK_NUMBER" --rpc-url "$candidate" >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + done + + echo "Error: could not find a working Ethereum RPC endpoint." >&2 + echo "Tried LIDO_V3_RPC_URL, RPC_URL, ETH_RPC_URL, TRACE_RPC_URL, and the public fallback." >&2 + exit 1 +} + +read_storage() { + local contract=$1 + local slot=$2 + cast storage "$contract" "$slot" --block "$BLOCK_NUMBER" --rpc-url "$RPC_URL" +} + +RPC_URL=$(resolve_rpc_url) + +echo "Reading Lido V3 raw storage at block $BLOCK_NUMBER from $RPC_URL..." >&2 + +total_and_external_shares=$(read_storage "$STETH_PROXY" "$TOTAL_AND_EXTERNAL_SHARES_SLOT") +buffered_ether_and_deposited_validators=$(read_storage "$STETH_PROXY" "$BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_SLOT") +cl_balance_and_cl_validators=$(read_storage "$STETH_PROXY" "$CL_BALANCE_AND_CL_VALIDATORS_SLOT") +staking_state=$(read_storage "$STETH_PROXY" "$STAKING_STATE_SLOT") + +cat < Result { + let initial_state = InitialState::parse(¶ms)?; + + if block.number != initial_state.start_block { + return Ok(BlockTransactionProtocolComponents { tx_components: vec![] }); + } + + let tx = block + .transactions() + .next() + .ok_or_else(|| anyhow!("Activation block has no transactions"))?; + + Ok(BlockTransactionProtocolComponents { + tx_components: vec![TransactionProtocolComponents { + tx: Some(tx.into()), + components: create_components(), + }], + }) +} + +fn create_components() -> Vec { + vec![ + ProtocolComponent::new(STETH_COMPONENT_ID) + .with_tokens(&[STETH_ADDRESS, ETH_ADDRESS]) + .with_attributes(&[(TOKEN_TO_TRACK_TOTAL_POOLED_ETH_ATTR, ETH_ADDRESS.as_ref())]) + .as_swap_type("lido_v3_pool", ImplementationType::Custom), + ProtocolComponent::new(WSTETH_COMPONENT_ID) + .with_tokens(&[STETH_ADDRESS, WSTETH_ADDRESS]) + .with_attributes(&[(TOKEN_TO_TRACK_TOTAL_POOLED_ETH_ATTR, STETH_ADDRESS.as_ref())]) + .as_swap_type("lido_v3_pool", ImplementationType::Custom), + ] +} + +#[substreams::handlers::map] +pub fn map_protocol_changes( + params: String, + block: eth::v2::Block, + protocol_components: BlockTransactionProtocolComponents, +) -> Result { + let initial_state = InitialState::parse(¶ms)?; + let mut transaction_changes: HashMap = HashMap::new(); + + if !protocol_components + .tx_components + .is_empty() + { + initialize_protocol_components( + &initial_state, + protocol_components, + &mut transaction_changes, + )?; + } else { + handle_state_updates(&block, &mut transaction_changes); + } + + Ok(BlockChanges { + block: Some((&block).into()), + changes: transaction_changes + .drain() + .sorted_unstable_by_key(|(index, _)| *index) + .filter_map(|(_, builder)| builder.build()) + .collect(), + storage_changes: vec![], + }) +} + +fn initialize_protocol_components( + initial_state: &InitialState, + protocol_components: BlockTransactionProtocolComponents, + transaction_changes: &mut HashMap, +) -> Result<()> { + let tx_component = protocol_components + .tx_components + .into_iter() + .next() + .ok_or_else(|| anyhow!("Missing activation transaction component"))?; + let tx = tx_component + .tx + .as_ref() + .ok_or_else(|| anyhow!("Activation transaction missing"))?; + + let builder = transaction_changes + .entry(tx.index) + .or_insert_with(|| TransactionChangesBuilder::new(tx)); + + for component in tx_component.components { + builder.add_protocol_component(&component); + } + + builder.add_entity_change(&EntityChanges { + component_id: STETH_COMPONENT_ID.to_string(), + attributes: initial_state.steth_creation_attributes()?, + }); + builder.add_entity_change(&EntityChanges { + component_id: WSTETH_COMPONENT_ID.to_string(), + attributes: initial_state.wsteth_creation_attributes()?, + }); + + Ok(()) +} + +fn handle_state_updates( + block: ð::v2::Block, + transaction_changes: &mut HashMap, +) { + for tx in block.transactions() { + for call in tx + .calls + .iter() + .filter(|call| !call.state_reverted) + { + for storage_change in call + .storage_changes + .iter() + .filter(|change| change.address == STETH_ADDRESS) + { + let Some((attr_name, shared_between_components)) = + tracked_attribute(&storage_change.key) + else { + continue; + }; + + let builder = transaction_changes + .entry(tx.index as u64) + .or_insert_with(|| TransactionChangesBuilder::new(&(tx.into()))); + + builder.add_entity_change(&EntityChanges { + component_id: STETH_COMPONENT_ID.to_string(), + attributes: vec![attribute_with_bytes( + attr_name, + &storage_change.new_value, + ChangeType::Update, + )], + }); + + if shared_between_components { + builder.add_entity_change(&EntityChanges { + component_id: WSTETH_COMPONENT_ID.to_string(), + attributes: vec![attribute_with_bytes( + attr_name, + &storage_change.new_value, + ChangeType::Update, + )], + }); + } + } + } + } +} + +fn tracked_attribute(slot: &[u8]) -> Option<(&'static str, bool)> { + if slot == TOTAL_AND_EXTERNAL_SHARES_POSITION { + Some((TOTAL_AND_EXTERNAL_SHARES_ATTR, true)) + } else if slot == BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_POSITION { + Some((BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR, true)) + } else if slot == CL_BALANCE_AND_CL_VALIDATORS_POSITION { + Some((CL_BALANCE_AND_CL_VALIDATORS_ATTR, true)) + } else if slot == STAKING_STATE_POSITION { + Some((STAKING_STATE_ATTR, false)) + } else { + None + } +} diff --git a/protocols/substreams/ethereum-lido-v3/src/modules/1_map_protocol_components.rs b/protocols/substreams/ethereum-lido-v3/src/modules/1_map_protocol_components.rs deleted file mode 100644 index 47789428c3..0000000000 --- a/protocols/substreams/ethereum-lido-v3/src/modules/1_map_protocol_components.rs +++ /dev/null @@ -1,48 +0,0 @@ -use anyhow::{anyhow, Result}; -use substreams_ethereum::pb::eth; -use tycho_substreams::{ - models::{ImplementationType, ProtocolComponent}, - prelude::{BlockTransactionProtocolComponents, TransactionProtocolComponents}, -}; - -use crate::{ - constants::{ - ETH_ADDRESS, STETH_ADDRESS, STETH_COMPONENT_ID, WSTETH_ADDRESS, WSTETH_COMPONENT_ID, - }, - state::InitialState, -}; - -#[substreams::handlers::map] -pub fn map_protocol_components( - params: String, - block: eth::v2::Block, -) -> Result { - let initial_state = InitialState::parse(¶ms)?; - - if block.number != initial_state.start_block { - return Ok(BlockTransactionProtocolComponents { tx_components: vec![] }); - } - - let tx = block - .transactions() - .next() - .ok_or_else(|| anyhow!("Activation block has no transactions"))?; - - Ok(BlockTransactionProtocolComponents { - tx_components: vec![TransactionProtocolComponents { - tx: Some(tx.into()), - components: create_components(), - }], - }) -} - -fn create_components() -> Vec { - vec![ - ProtocolComponent::new(STETH_COMPONENT_ID) - .with_tokens(&[STETH_ADDRESS, ETH_ADDRESS]) - .as_swap_type("stETH", ImplementationType::Custom), - ProtocolComponent::new(WSTETH_COMPONENT_ID) - .with_tokens(&[STETH_ADDRESS, WSTETH_ADDRESS]) - .as_swap_type("wstETH", ImplementationType::Custom), - ] -} diff --git a/protocols/substreams/ethereum-lido-v3/src/modules/2_store_protocol_state.rs b/protocols/substreams/ethereum-lido-v3/src/modules/2_store_protocol_state.rs deleted file mode 100644 index c02519140c..0000000000 --- a/protocols/substreams/ethereum-lido-v3/src/modules/2_store_protocol_state.rs +++ /dev/null @@ -1,92 +0,0 @@ -use anyhow::Result; -use substreams::{ - prelude::StoreSetBigInt, - scalar::BigInt, - store::{StoreNew, StoreSet}, -}; -use substreams_ethereum::pb::eth; - -use crate::{ - constants::{ - BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_POSITION, BUFFERED_ETHER_ATTR, - CL_BALANCE_AND_CL_VALIDATORS_POSITION, CL_BALANCE_ATTR, CL_VALIDATORS_ATTR, - DEPOSITED_VALIDATORS_ATTR, EXTERNAL_SHARES_ATTR, STAKING_STATE_ATTR, - STAKING_STATE_POSITION, STETH_ADDRESS, STETH_COMPONENT_ID, - TOTAL_AND_EXTERNAL_SHARES_POSITION, TOTAL_SHARES_ATTR, - }, - state::{InitialState, LidoProtocolState}, - utils::decode_packed_uint128_pair, -}; - -#[substreams::handlers::store] -pub fn store_protocol_state(params: String, block: eth::v2::Block, store: StoreSetBigInt) { - store_protocol_state_inner(¶ms, &block, &store).expect("Failed to store Lido V3 state"); -} - -fn store_protocol_state_inner( - params: &str, - block: ð::v2::Block, - store: &StoreSetBigInt, -) -> Result<()> { - let initial_state = InitialState::parse(params)?; - - if block.number == initial_state.start_block { - let initial_state = LidoProtocolState::from_initial(&initial_state)?; - set_attr(0, TOTAL_SHARES_ATTR, &initial_state.total_shares, store); - set_attr(0, EXTERNAL_SHARES_ATTR, &initial_state.external_shares, store); - set_attr(0, BUFFERED_ETHER_ATTR, &initial_state.buffered_ether, store); - set_attr(0, DEPOSITED_VALIDATORS_ATTR, &initial_state.deposited_validators, store); - set_attr(0, CL_BALANCE_ATTR, &initial_state.cl_balance, store); - set_attr(0, CL_VALIDATORS_ATTR, &initial_state.cl_validators, store); - set_attr(0, STAKING_STATE_ATTR, &initial_state.staking_state, store); - } - - for tx in block.transactions() { - for call in tx - .calls - .iter() - .filter(|call| !call.state_reverted) - { - for storage_change in call - .storage_changes - .iter() - .filter(|change| change.address == STETH_ADDRESS) - { - if storage_change.key == TOTAL_AND_EXTERNAL_SHARES_POSITION { - let (total_shares, external_shares) = - decode_packed_uint128_pair(&storage_change.new_value); - set_attr(call.begin_ordinal, TOTAL_SHARES_ATTR, &total_shares, store); - set_attr(call.begin_ordinal, EXTERNAL_SHARES_ATTR, &external_shares, store); - } else if storage_change.key == BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_POSITION { - let (buffered_ether, deposited_validators) = - decode_packed_uint128_pair(&storage_change.new_value); - set_attr(call.begin_ordinal, BUFFERED_ETHER_ATTR, &buffered_ether, store); - set_attr( - call.begin_ordinal, - DEPOSITED_VALIDATORS_ATTR, - &deposited_validators, - store, - ); - } else if storage_change.key == CL_BALANCE_AND_CL_VALIDATORS_POSITION { - let (cl_balance, cl_validators) = - decode_packed_uint128_pair(&storage_change.new_value); - set_attr(call.begin_ordinal, CL_BALANCE_ATTR, &cl_balance, store); - set_attr(call.begin_ordinal, CL_VALIDATORS_ATTR, &cl_validators, store); - } else if storage_change.key == STAKING_STATE_POSITION { - set_attr( - call.begin_ordinal, - STAKING_STATE_ATTR, - &BigInt::from_unsigned_bytes_be(&storage_change.new_value), - store, - ); - } - } - } - } - - Ok(()) -} - -fn set_attr(ordinal: u64, attr: &str, value: &BigInt, store: &StoreSetBigInt) { - store.set(ordinal, format!("{STETH_COMPONENT_ID}:{attr}"), value); -} diff --git a/protocols/substreams/ethereum-lido-v3/src/modules/3_map_protocol_changes.rs b/protocols/substreams/ethereum-lido-v3/src/modules/3_map_protocol_changes.rs deleted file mode 100644 index e21d7c122e..0000000000 --- a/protocols/substreams/ethereum-lido-v3/src/modules/3_map_protocol_changes.rs +++ /dev/null @@ -1,267 +0,0 @@ -use anyhow::{anyhow, Result}; -use itertools::Itertools; -use std::collections::HashMap; -use substreams::{ - pb::substreams::StoreDeltas, - scalar::BigInt, - store::{StoreGet, StoreGetBigInt}, -}; -use substreams_ethereum::pb::eth; -use tycho_substreams::{ - models::{BalanceChange, BlockChanges, ChangeType, EntityChanges, TransactionChangesBuilder}, - prelude::BlockTransactionProtocolComponents, -}; - -use crate::{ - constants::{ - BUFFERED_ETHER_ATTR, CL_BALANCE_ATTR, CL_VALIDATORS_ATTR, DEPOSITED_VALIDATORS_ATTR, - ETH_ADDRESS, EXTERNAL_SHARES_ATTR, STAKING_STATE_ATTR, STETH_ADDRESS, STETH_COMPONENT_ID, - TOTAL_SHARES_ATTR, WSTETH_COMPONENT_ID, - }, - state::{InitialState, LidoProtocolState}, - utils::{attribute_with_bigint, bigint_from_store_value}, -}; - -#[substreams::handlers::map] -pub fn map_protocol_changes( - params: String, - block: eth::v2::Block, - protocol_components: BlockTransactionProtocolComponents, - storage_deltas: StoreDeltas, - storage_store: StoreGetBigInt, -) -> Result { - let initial_state = InitialState::parse(¶ms)?; - let mut transaction_changes: HashMap = HashMap::new(); - - if !protocol_components - .tx_components - .is_empty() - { - initialize_protocol_components( - &initial_state, - protocol_components, - &mut transaction_changes, - )?; - } else { - handle_state_updates(&block, &storage_deltas, &storage_store, &mut transaction_changes)?; - } - - Ok(BlockChanges { - block: Some((&block).into()), - changes: transaction_changes - .drain() - .sorted_unstable_by_key(|(index, _)| *index) - .filter_map(|(_, builder)| builder.build()) - .collect(), - storage_changes: vec![], - }) -} - -fn initialize_protocol_components( - initial_state: &InitialState, - protocol_components: BlockTransactionProtocolComponents, - transaction_changes: &mut HashMap, -) -> Result<()> { - let state = LidoProtocolState::from_initial(initial_state)?; - - let tx_component = protocol_components - .tx_components - .into_iter() - .next() - .ok_or_else(|| anyhow!("Missing activation transaction component"))?; - let tx = tx_component - .tx - .as_ref() - .ok_or_else(|| anyhow!("Activation transaction missing"))?; - - let builder = transaction_changes - .entry(tx.index) - .or_insert_with(|| TransactionChangesBuilder::new(tx)); - - for component in tx_component.components { - builder.add_protocol_component(&component); - } - - builder.add_entity_change(&EntityChanges { - component_id: STETH_COMPONENT_ID.to_string(), - attributes: state.steth_creation_attributes(), - }); - builder.add_entity_change(&EntityChanges { - component_id: WSTETH_COMPONENT_ID.to_string(), - attributes: state.shared_creation_attributes(), - }); - - let internal_ether = state - .internal_ether() - .to_signed_bytes_be(); - builder.add_balance_change(&BalanceChange { - token: ETH_ADDRESS.to_vec(), - balance: internal_ether.clone(), - component_id: STETH_COMPONENT_ID.as_bytes().to_vec(), - }); - builder.add_balance_change(&BalanceChange { - token: STETH_ADDRESS.to_vec(), - balance: internal_ether, - component_id: WSTETH_COMPONENT_ID.as_bytes().to_vec(), - }); - - Ok(()) -} - -fn handle_state_updates( - block: ð::v2::Block, - storage_deltas: &StoreDeltas, - storage_store: &StoreGetBigInt, - transaction_changes: &mut HashMap, -) -> Result<()> { - let mut sorted_deltas = storage_deltas - .deltas - .iter() - .filter(|delta| { - delta - .key - .starts_with(&format!("{STETH_COMPONENT_ID}:")) - }) - .collect::>(); - sorted_deltas.sort_by_key(|delta| delta.ordinal); - - if sorted_deltas.is_empty() { - return Ok(()); - } - - let mut current_state = LidoProtocolState { - total_shares: get_initial_value_for_block( - &sorted_deltas, - storage_store, - TOTAL_SHARES_ATTR, - )?, - external_shares: get_initial_value_for_block( - &sorted_deltas, - storage_store, - EXTERNAL_SHARES_ATTR, - )?, - buffered_ether: get_initial_value_for_block( - &sorted_deltas, - storage_store, - BUFFERED_ETHER_ATTR, - )?, - deposited_validators: get_initial_value_for_block( - &sorted_deltas, - storage_store, - DEPOSITED_VALIDATORS_ATTR, - )?, - cl_balance: get_initial_value_for_block(&sorted_deltas, storage_store, CL_BALANCE_ATTR)?, - cl_validators: get_initial_value_for_block( - &sorted_deltas, - storage_store, - CL_VALIDATORS_ATTR, - )?, - staking_state: get_initial_value_for_block( - &sorted_deltas, - storage_store, - STAKING_STATE_ATTR, - )?, - }; - - for delta in &sorted_deltas { - let attr_name = delta - .key - .split(':') - .next_back() - .ok_or_else(|| anyhow!("Unexpected store key format: {}", delta.key))?; - let attr_value = bigint_from_store_value(&delta.new_value)?; - current_state.apply_attribute(attr_name, attr_value.clone())?; - - let tx = transaction_for_ordinal(block, delta.ordinal) - .ok_or_else(|| anyhow!("No transaction found for ordinal {}", delta.ordinal))?; - let builder = transaction_changes - .entry(tx.index as u64) - .or_insert_with(|| TransactionChangesBuilder::new(&(tx.into()))); - - if attr_name == STAKING_STATE_ATTR { - builder.add_entity_change(&EntityChanges { - component_id: STETH_COMPONENT_ID.to_string(), - attributes: vec![attribute_with_bigint( - STAKING_STATE_ATTR, - &attr_value, - ChangeType::Update, - )], - }); - } else { - let attribute = attribute_with_bigint(attr_name, &attr_value, ChangeType::Update); - builder.add_entity_change(&EntityChanges { - component_id: STETH_COMPONENT_ID.to_string(), - attributes: vec![attribute.clone()], - }); - builder.add_entity_change(&EntityChanges { - component_id: WSTETH_COMPONENT_ID.to_string(), - attributes: vec![attribute], - }); - } - - let is_last_delta_for_ordinal = sorted_deltas - .iter() - .rfind(|candidate| candidate.ordinal == delta.ordinal) - .map(|last_delta| std::ptr::eq(*last_delta, *delta)) - .unwrap_or(false); - - if !is_last_delta_for_ordinal { - continue; - } - - let shared_update_attributes = current_state.shared_update_attributes(); - builder.add_entity_change(&EntityChanges { - component_id: STETH_COMPONENT_ID.to_string(), - attributes: shared_update_attributes.clone(), - }); - builder.add_entity_change(&EntityChanges { - component_id: WSTETH_COMPONENT_ID.to_string(), - attributes: shared_update_attributes, - }); - - let internal_ether = current_state - .internal_ether() - .to_signed_bytes_be(); - builder.add_balance_change(&BalanceChange { - token: ETH_ADDRESS.to_vec(), - balance: internal_ether.clone(), - component_id: STETH_COMPONENT_ID.as_bytes().to_vec(), - }); - builder.add_balance_change(&BalanceChange { - token: STETH_ADDRESS.to_vec(), - balance: internal_ether, - component_id: WSTETH_COMPONENT_ID.as_bytes().to_vec(), - }); - } - - Ok(()) -} - -fn get_initial_value_for_block( - deltas: &[&substreams::pb::substreams::StoreDelta], - storage_store: &StoreGetBigInt, - suffix: &str, -) -> Result { - if let Some(first_delta) = deltas - .iter() - .filter(|delta| delta.key.ends_with(suffix)) - .min_by_key(|delta| delta.ordinal) - { - return bigint_from_store_value(&first_delta.old_value); - } - - Ok(storage_store - .get_last(format!("{STETH_COMPONENT_ID}:{suffix}")) - .unwrap_or_else(|| BigInt::from(0))) -} - -fn transaction_for_ordinal( - block: ð::v2::Block, - ordinal: u64, -) -> Option<ð::v2::TransactionTrace> { - block.transactions().find(|tx| { - tx.calls - .iter() - .any(|call| call.begin_ordinal <= ordinal && call.end_ordinal >= ordinal) - }) -} diff --git a/protocols/substreams/ethereum-lido-v3/src/modules/mod.rs b/protocols/substreams/ethereum-lido-v3/src/modules/mod.rs deleted file mode 100644 index 2edba7fb44..0000000000 --- a/protocols/substreams/ethereum-lido-v3/src/modules/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[path = "1_map_protocol_components.rs"] -mod map_protocol_components; - -#[path = "2_store_protocol_state.rs"] -mod store_protocol_state; - -#[path = "3_map_protocol_changes.rs"] -mod map_protocol_changes; diff --git a/protocols/substreams/ethereum-lido-v3/src/state.rs b/protocols/substreams/ethereum-lido-v3/src/state.rs index b174ad5f55..3cc38c8bb7 100644 --- a/protocols/substreams/ethereum-lido-v3/src/state.rs +++ b/protocols/substreams/ethereum-lido-v3/src/state.rs @@ -1,109 +1,21 @@ use anyhow::{anyhow, Result}; use serde::Deserialize; -use substreams::scalar::BigInt; use tycho_substreams::models::{Attribute, ChangeType}; use crate::{ constants::{ - BUFFERED_ETHER_ATTR, CL_BALANCE_ATTR, CL_VALIDATORS_ATTR, DEPOSITED_VALIDATORS_ATTR, - EXTERNAL_SHARES_ATTR, INTERNAL_ETHER_ATTR, INTERNAL_SHARES_ATTR, STAKING_STATE_ATTR, - TOTAL_SHARES_ATTR, + BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR, CL_BALANCE_AND_CL_VALIDATORS_ATTR, + STAKING_STATE_ATTR, TOTAL_AND_EXTERNAL_SHARES_ATTR, }, - utils::{attribute_with_bigint, bigint_from_hex}, + utils::{attribute_with_bytes, bytes_from_hex}, }; -#[derive(Clone, Debug, Default)] -pub struct LidoProtocolState { - pub total_shares: BigInt, - pub external_shares: BigInt, - pub buffered_ether: BigInt, - pub deposited_validators: BigInt, - pub cl_balance: BigInt, - pub cl_validators: BigInt, - pub staking_state: BigInt, -} - -impl LidoProtocolState { - pub fn from_initial(initial_state: &InitialState) -> Result { - Ok(Self { - total_shares: bigint_from_hex(&initial_state.total_shares)?, - external_shares: bigint_from_hex(&initial_state.external_shares)?, - buffered_ether: bigint_from_hex(&initial_state.buffered_ether)?, - deposited_validators: bigint_from_hex(&initial_state.deposited_validators)?, - cl_balance: bigint_from_hex(&initial_state.cl_balance)?, - cl_validators: bigint_from_hex(&initial_state.cl_validators)?, - staking_state: bigint_from_hex(&initial_state.staking_state)?, - }) - } - - pub fn apply_attribute(&mut self, name: &str, value: BigInt) -> Result<()> { - match name { - TOTAL_SHARES_ATTR => self.total_shares = value, - EXTERNAL_SHARES_ATTR => self.external_shares = value, - BUFFERED_ETHER_ATTR => self.buffered_ether = value, - DEPOSITED_VALIDATORS_ATTR => self.deposited_validators = value, - CL_BALANCE_ATTR => self.cl_balance = value, - CL_VALIDATORS_ATTR => self.cl_validators = value, - STAKING_STATE_ATTR => self.staking_state = value, - _ => return Err(anyhow!("Unknown Lido V3 attribute: {name}")), - } - - Ok(()) - } - - pub fn internal_ether(&self) -> BigInt { - let deposit_size = num_bigint::BigInt::parse_bytes(b"32000000000000000000", 10) - .expect("Failed to parse Lido deposit size"); - let transient_ether = - (&self.deposited_validators - &self.cl_validators) * BigInt::from(deposit_size); - &self.buffered_ether + &self.cl_balance + transient_ether - } - - pub fn internal_shares(&self) -> BigInt { - &self.total_shares - &self.external_shares - } - - pub fn shared_creation_attributes(&self) -> Vec { - self.shared_attributes(ChangeType::Creation) - } - - pub fn shared_update_attributes(&self) -> Vec { - self.shared_attributes(ChangeType::Update) - } - - pub fn steth_creation_attributes(&self) -> Vec { - let mut attributes = self.shared_creation_attributes(); - attributes.push(attribute_with_bigint( - STAKING_STATE_ATTR, - &self.staking_state, - ChangeType::Creation, - )); - attributes - } - - fn shared_attributes(&self, change: ChangeType) -> Vec { - vec![ - attribute_with_bigint(TOTAL_SHARES_ATTR, &self.total_shares, change), - attribute_with_bigint(EXTERNAL_SHARES_ATTR, &self.external_shares, change), - attribute_with_bigint(BUFFERED_ETHER_ATTR, &self.buffered_ether, change), - attribute_with_bigint(DEPOSITED_VALIDATORS_ATTR, &self.deposited_validators, change), - attribute_with_bigint(CL_BALANCE_ATTR, &self.cl_balance, change), - attribute_with_bigint(CL_VALIDATORS_ATTR, &self.cl_validators, change), - attribute_with_bigint(INTERNAL_ETHER_ATTR, &self.internal_ether(), change), - attribute_with_bigint(INTERNAL_SHARES_ATTR, &self.internal_shares(), change), - ] - } -} - #[derive(Clone, Debug, Deserialize)] pub struct InitialState { pub start_block: u64, - pub total_shares: String, - pub external_shares: String, - pub buffered_ether: String, - pub deposited_validators: String, - pub cl_balance: String, - pub cl_validators: String, + pub total_and_external_shares: String, + pub buffered_ether_and_deposited_validators: String, + pub cl_balance_and_cl_validators: String, pub staking_state: String, } @@ -112,4 +24,38 @@ impl InitialState { serde_json::from_str(params) .map_err(|e| anyhow!("Failed to parse Lido V3 initial state: {e}")) } + + pub fn steth_creation_attributes(&self) -> Result> { + let mut attributes = self.shared_creation_attributes()?; + attributes.push(attribute_with_bytes( + STAKING_STATE_ATTR, + &bytes_from_hex(&self.staking_state)?, + ChangeType::Creation, + )); + Ok(attributes) + } + + pub fn wsteth_creation_attributes(&self) -> Result> { + self.shared_creation_attributes() + } + + fn shared_creation_attributes(&self) -> Result> { + Ok(vec![ + attribute_with_bytes( + TOTAL_AND_EXTERNAL_SHARES_ATTR, + &bytes_from_hex(&self.total_and_external_shares)?, + ChangeType::Creation, + ), + attribute_with_bytes( + BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR, + &bytes_from_hex(&self.buffered_ether_and_deposited_validators)?, + ChangeType::Creation, + ), + attribute_with_bytes( + CL_BALANCE_AND_CL_VALIDATORS_ATTR, + &bytes_from_hex(&self.cl_balance_and_cl_validators)?, + ChangeType::Creation, + ), + ]) + } } diff --git a/protocols/substreams/ethereum-lido-v3/src/utils.rs b/protocols/substreams/ethereum-lido-v3/src/utils.rs index 4415082ecb..e6c1df5b70 100644 --- a/protocols/substreams/ethereum-lido-v3/src/utils.rs +++ b/protocols/substreams/ethereum-lido-v3/src/utils.rs @@ -1,93 +1,28 @@ use anyhow::{anyhow, Result}; -use substreams::scalar::BigInt; use tycho_substreams::models::{Attribute, ChangeType}; -pub fn attribute_with_bigint(name: &str, value: &BigInt, change: ChangeType) -> Attribute { - Attribute { name: name.to_string(), value: value.to_signed_bytes_be(), change: change.into() } +pub fn attribute_with_bytes(name: &str, value: &[u8], change: ChangeType) -> Attribute { + Attribute { name: name.to_string(), value: value.to_vec(), change: change.into() } } -pub fn bigint_from_hex(value: &str) -> Result { +pub fn bytes_from_hex(value: &str) -> Result> { let value = value .strip_prefix("0x") .unwrap_or(value); - let bytes = hex::decode(value).map_err(|e| anyhow!("Failed to decode hex value: {e}"))?; - Ok(BigInt::from_unsigned_bytes_be(&bytes)) -} - -pub fn bigint_from_store_value(value: &[u8]) -> Result { - if value.is_empty() { - return Ok(BigInt::from(0)); - } - - let value_str = - std::str::from_utf8(value).map_err(|e| anyhow!("Invalid UTF-8 store value: {e}"))?; - let parsed = num_bigint::BigInt::parse_bytes(value_str.as_bytes(), 10) - .ok_or_else(|| anyhow!("Failed to parse decimal store value: {value_str}"))?; - - Ok(BigInt::from(parsed)) -} - -pub fn decode_packed_uint128_pair(raw: &[u8]) -> (BigInt, BigInt) { - let low = read_bytes(raw, 0, 16); - let high = read_bytes(raw, 16, 16); - (BigInt::from_unsigned_bytes_be(low), BigInt::from_unsigned_bytes_be(high)) -} - -pub fn read_bytes(buf: &[u8], offset: usize, number_of_bytes: usize) -> &[u8] { - let buf_length = buf.len(); - if buf_length < number_of_bytes { - panic!( - "attempting to read {number_of_bytes} bytes in buffer size {buf_size}", - buf_size = buf.len() - ) - } - - if offset > (buf_length - 1) { - panic!("offset {offset} exceeds buffer size {buf_size}", buf_size = buf.len()) - } - - let end = buf_length - 1 - offset; - let start = (end + 1) - .checked_sub(number_of_bytes) - .unwrap_or_else(|| { - panic!( - "number of bytes {number_of_bytes} with offset {offset} exceeds buffer size \ -{buf_size}", - buf_size = buf.len() - ) - }); - - &buf[start..=end] + hex::decode(value).map_err(|e| anyhow!("Failed to decode hex value: {e}")) } #[cfg(test)] mod tests { - use super::{decode_packed_uint128_pair, read_bytes}; - use substreams::hex; + use super::bytes_from_hex; #[test] - fn read_low_and_high_uint128_halves() { - let raw = hex!("00000000000000000000000000065004000000000000002303b296dd9f3631db"); - let (low, high) = decode_packed_uint128_pair(&raw); - - assert_eq!( - low, - substreams::scalar::BigInt::from_unsigned_bytes_be(&hex!( - "000000000000002303b296dd9f3631db" - )) - ); - assert_eq!( - high, - substreams::scalar::BigInt::from_unsigned_bytes_be(&hex!( - "00000000000000000000000000065004" - )) - ); + fn decode_prefixed_hex() { + assert_eq!(bytes_from_hex("0xaabbcc").unwrap(), vec![0xaa, 0xbb, 0xcc]); } #[test] - fn read_bytes_from_low_side() { - let raw = hex!("aabbccdd"); - assert_eq!(read_bytes(&raw, 0, 2), hex!("ccdd")); - assert_eq!(read_bytes(&raw, 2, 2), hex!("aabb")); + fn decode_unprefixed_hex() { + assert_eq!(bytes_from_hex("aabbcc").unwrap(), vec![0xaa, 0xbb, 0xcc]); } } diff --git a/protocols/substreams/ethereum-lido-v3/substreams.yaml b/protocols/substreams/ethereum-lido-v3/substreams.yaml index 745d5982b7..dff723b33d 100644 --- a/protocols/substreams/ethereum-lido-v3/substreams.yaml +++ b/protocols/substreams/ethereum-lido-v3/substreams.yaml @@ -18,18 +18,16 @@ binaries: network: mainnet params: + # Recompute these raw slot values with: + # RPC_URL= ./scripts/compute_initial_state.sh map_protocol_components: &initial_state | { "start_block": 24083113, - "total_shares": "0x00000000000000000000000000000000000000000005f18d02edc955cbcfc9b0", - "external_shares": "0x0000000000000000000000000000000000000000000000000000000000000000", - "buffered_ether": "0x00000000000000000000000000000000000000000000002303b296dd9f3631db", - "deposited_validators": "0x0000000000000000000000000000000000000000000000000000000000065004", - "cl_balance": "0x000000000000000000000000000000000000000000073f77f7680e9a096c4600", - "cl_validators": "0x0000000000000000000000000000000000000000000000000000000000064d49", + "total_and_external_shares": "0x00000000000000000000000000000000000000000005f18d02edc955cbcfc9b0", + "buffered_ether_and_deposited_validators": "0x00000000000000000000000000065004000000000000002303b296dd9f3631db", + "cl_balance_and_cl_validators": "0x00000000000000000000000000064d490000000000073f77f7680e9a096c4600", "staking_state": "0x00001fc3842bd1f071c000000000190000001fc37ee5c9f3db1a0000016f7a9f" } - store_protocol_state: *initial_state map_protocol_changes: *initial_state modules: @@ -42,15 +40,6 @@ modules: output: type: proto:tycho.evm.v1.BlockTransactionProtocolComponents - - name: store_protocol_state - kind: store - initialBlock: *start_block - updatePolicy: set - valueType: bigint - inputs: - - params: string - - source: sf.ethereum.type.v2.Block - - name: map_protocol_changes kind: map initialBlock: *start_block @@ -58,8 +47,5 @@ modules: - params: string - source: sf.ethereum.type.v2.Block - map: map_protocol_components - - store: store_protocol_state - mode: deltas - - store: store_protocol_state output: type: proto:tycho.evm.v1.BlockChanges From 57b328eeae7a315a08f72aa880a0d6ed064f1a31 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 21 Apr 2026 11:16:52 +0800 Subject: [PATCH 3/6] chore: add lido v3 to workspace --- protocols/substreams/Cargo.lock | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/protocols/substreams/Cargo.lock b/protocols/substreams/Cargo.lock index 65e4904739..573222e0bc 100644 --- a/protocols/substreams/Cargo.lock +++ b/protocols/substreams/Cargo.lock @@ -399,6 +399,21 @@ dependencies = [ "tycho-substreams 0.6.0", ] +[[package]] +name = "ethereum-lido-v3" +version = "0.1.0" +dependencies = [ + "anyhow", + "hex", + "itertools 0.10.5", + "num-bigint", + "serde", + "serde_json", + "substreams", + "substreams-ethereum", + "tycho-substreams 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "ethereum-maverick-v2" version = "0.1.1" From a37d4b6288d3d04f3946647ee61ded92e58e8ec6 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 21 Apr 2026 11:54:34 +0800 Subject: [PATCH 4/6] feat(tycho-simulation): add lido v3 simulation --- crates/tycho-simulation/Cargo.toml | 2 +- .../src/evm/protocol/lido_v3/decoder.rs | 109 +++ .../src/evm/protocol/lido_v3/mod.rs | 2 + .../src/evm/protocol/lido_v3/state.rs | 822 ++++++++++++++++++ .../tycho-simulation/src/evm/protocol/mod.rs | 2 +- 5 files changed, 935 insertions(+), 2 deletions(-) create mode 100644 crates/tycho-simulation/src/evm/protocol/lido_v3/decoder.rs create mode 100644 crates/tycho-simulation/src/evm/protocol/lido_v3/mod.rs create mode 100644 crates/tycho-simulation/src/evm/protocol/lido_v3/state.rs diff --git a/crates/tycho-simulation/Cargo.toml b/crates/tycho-simulation/Cargo.toml index 350f9cd124..41077c1fa7 100644 --- a/crates/tycho-simulation/Cargo.toml +++ b/crates/tycho-simulation/Cargo.toml @@ -53,7 +53,7 @@ tycho-common = { workspace = true } tycho-ethereum = { workspace = true } # EVM dependencies -alloy = { workspace = true, features = ["sol-types", "rand", "dyn-abi", "json-abi"], optional = true } +alloy = { workspace = true, features = ["sol-types", "rand", "dyn-abi", "json-abi", "eip712"], optional = true } foundry-block-explorers = { version = "0.22.0", optional = true } num-bigint = { workspace = true } revm = { version = "29.0.1", features = ["alloydb", "serde", "optional_eip3607"], optional = true } diff --git a/crates/tycho-simulation/src/evm/protocol/lido_v3/decoder.rs b/crates/tycho-simulation/src/evm/protocol/lido_v3/decoder.rs new file mode 100644 index 0000000000..6c1176df56 --- /dev/null +++ b/crates/tycho-simulation/src/evm/protocol/lido_v3/decoder.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +use alloy::primitives::U256; +use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader}; +use tycho_common::{models::token::Token, Bytes}; + +use super::state::{ + LidoV3PoolKind, LidoV3State, StakingState, BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR, + CL_BALANCE_AND_CL_VALIDATORS_ATTR, STAKING_STATE_ATTR, STETH_COMPONENT_ID, + TOTAL_AND_EXTERNAL_SHARES_ATTR, WSTETH_COMPONENT_ID, +}; +use crate::protocol::{ + errors::InvalidSnapshotError, + models::{DecoderContext, TryFromWithBlock}, +}; + +impl TryFromWithBlock for LidoV3State { + type Error = InvalidSnapshotError; + + async fn try_from_with_header( + snapshot: ComponentWithState, + block: BlockHeader, + _account_balances: &HashMap>, + _all_tokens: &HashMap, + _decoder_context: &DecoderContext, + ) -> Result { + let kind = if snapshot + .component + .id + .eq_ignore_ascii_case(STETH_COMPONENT_ID) + { + LidoV3PoolKind::StEth + } else if snapshot + .component + .id + .eq_ignore_ascii_case(WSTETH_COMPONENT_ID) + { + LidoV3PoolKind::WstEth + } else { + return Err(InvalidSnapshotError::ValueError(format!( + "unknown Lido V3 component id {}", + snapshot.component.id + ))); + }; + + let total_and_external_shares = snapshot + .state + .attributes + .get(TOTAL_AND_EXTERNAL_SHARES_ATTR) + .ok_or_else(|| { + InvalidSnapshotError::MissingAttribute(TOTAL_AND_EXTERNAL_SHARES_ATTR.to_string()) + }) + .map(|value| U256::from_be_slice(value))?; + let (total_shares, external_shares) = + LidoV3State::split_low_high_u128(total_and_external_shares); + + let buffered_and_deposited = snapshot + .state + .attributes + .get(BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR) + .ok_or_else(|| { + InvalidSnapshotError::MissingAttribute( + BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR.to_string(), + ) + }) + .map(|value| U256::from_be_slice(value))?; + let (buffered_ether, deposited_validators) = + LidoV3State::split_low_high_u128(buffered_and_deposited); + + let cl_balance_and_validators = snapshot + .state + .attributes + .get(CL_BALANCE_AND_CL_VALIDATORS_ATTR) + .ok_or_else(|| { + InvalidSnapshotError::MissingAttribute( + CL_BALANCE_AND_CL_VALIDATORS_ATTR.to_string(), + ) + }) + .map(|value| U256::from_be_slice(value))?; + let (cl_balance, cl_validators) = + LidoV3State::split_low_high_u128(cl_balance_and_validators); + + let staking_state = match kind { + LidoV3PoolKind::StEth => Some(StakingState::from_u256(U256::from_be_slice( + snapshot + .state + .attributes + .get(STAKING_STATE_ATTR) + .ok_or_else(|| { + InvalidSnapshotError::MissingAttribute(STAKING_STATE_ATTR.to_string()) + })?, + ))), + LidoV3PoolKind::WstEth => None, + }; + + Ok(LidoV3State::new( + kind, + block.number, + block.timestamp, + total_shares, + external_shares, + buffered_ether, + deposited_validators, + cl_balance, + cl_validators, + staking_state, + )) + } +} diff --git a/crates/tycho-simulation/src/evm/protocol/lido_v3/mod.rs b/crates/tycho-simulation/src/evm/protocol/lido_v3/mod.rs new file mode 100644 index 0000000000..d69e577b97 --- /dev/null +++ b/crates/tycho-simulation/src/evm/protocol/lido_v3/mod.rs @@ -0,0 +1,2 @@ +mod decoder; +pub mod state; diff --git a/crates/tycho-simulation/src/evm/protocol/lido_v3/state.rs b/crates/tycho-simulation/src/evm/protocol/lido_v3/state.rs new file mode 100644 index 0000000000..26daf5ede0 --- /dev/null +++ b/crates/tycho-simulation/src/evm/protocol/lido_v3/state.rs @@ -0,0 +1,822 @@ +use std::{any::Any, collections::HashMap}; + +use alloy::primitives::U256; +use hex_literal::hex; +use num_bigint::BigUint; +use num_traits::ToPrimitive; +use serde::{Deserialize, Serialize}; +use tycho_common::{ + dto::ProtocolStateDelta, + models::token::Token, + simulation::{ + errors::{SimulationError, TransitionError}, + protocol_sim::{Balances, GetAmountOutResult, ProtocolSim}, + }, + Bytes, +}; + +use crate::evm::protocol::u256_num::{biguint_to_u256, u256_to_biguint, u256_to_f64}; + +pub const STETH_COMPONENT_ID: &str = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"; +pub const WSTETH_COMPONENT_ID: &str = "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"; + +pub const STETH_ADDRESS: [u8; 20] = hex!("ae7ab96520de3a18e5e111b5eaab095312d7fe84"); +pub const WSTETH_ADDRESS: [u8; 20] = hex!("7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0"); +pub const ETH_ADDRESS: [u8; 20] = hex!("0000000000000000000000000000000000000000"); + +pub const TOTAL_AND_EXTERNAL_SHARES_ATTR: &str = "total_and_external_shares"; +pub const BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR: &str = + "buffered_ether_and_deposited_validators"; +pub const CL_BALANCE_AND_CL_VALIDATORS_ATTR: &str = "cl_balance_and_cl_validators"; +pub const STAKING_STATE_ATTR: &str = "staking_state"; + +const DEPOSIT_SIZE: u128 = 32_000_000_000_000_000_000; +const UINT128_MAX_EXCLUSIVE: u128 = u128::MAX; + +const SUBMIT_GAS: u64 = 160_000; +const WRAP_GAS: u64 = 81_000; +const UNWRAP_GAS: u64 = 66_000; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LidoV3PoolKind { + StEth, + WstEth, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LidoV3State { + kind: LidoV3PoolKind, + block_number: u64, + block_timestamp: u64, + total_shares: U256, + external_shares: U256, + buffered_ether: U256, + deposited_validators: U256, + cl_balance: U256, + cl_validators: U256, + staking_state: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StakingState { + prev_stake_block_number: u32, + prev_stake_limit: U256, + max_stake_limit_growth_blocks: u32, + max_stake_limit: U256, +} + +impl LidoV3State { + #[allow(clippy::too_many_arguments)] + pub fn new( + kind: LidoV3PoolKind, + block_number: u64, + block_timestamp: u64, + total_shares: U256, + external_shares: U256, + buffered_ether: U256, + deposited_validators: U256, + cl_balance: U256, + cl_validators: U256, + staking_state: Option, + ) -> Self { + Self { + kind, + block_number, + block_timestamp, + total_shares, + external_shares, + buffered_ether, + deposited_validators, + cl_balance, + cl_validators, + staking_state, + } + } + + pub(crate) fn split_low_high_u128(value: U256) -> (U256, U256) { + let mask = U256::from(u128::MAX); + (value & mask, (value >> 128u32) & mask) + } + + fn validate_u128_bound(name: &str, value: U256) -> Result<(), SimulationError> { + if value >= U256::from(UINT128_MAX_EXCLUSIVE) { + return Err(SimulationError::InvalidInput( + format!("{name} exceeds uint128 bound"), + None, + )); + } + Ok(()) + } + + fn internal_shares(&self) -> Result { + if self.external_shares > self.total_shares { + return Err(SimulationError::FatalError( + "external shares exceed total shares".to_string(), + )); + } + Ok(self.total_shares - self.external_shares) + } + + fn transient_ether(&self) -> Result { + if self.cl_validators > self.deposited_validators { + return Err(SimulationError::FatalError( + "cl validators exceed deposited validators".to_string(), + )); + } + Ok((self.deposited_validators - self.cl_validators) * U256::from(DEPOSIT_SIZE)) + } + + fn internal_ether(&self) -> Result { + Ok(self.buffered_ether + self.cl_balance + self.transient_ether()?) + } + + fn shares_for_pooled_eth(&self, eth_amount: U256) -> Result { + Self::validate_u128_bound("eth amount", eth_amount)?; + let denominator = self.internal_shares()?; + let numerator = self.internal_ether()?; + if denominator.is_zero() || numerator.is_zero() { + return Err(SimulationError::FatalError("invalid Lido share rate state".to_string())); + } + Ok((eth_amount * denominator) / numerator) + } + + fn pooled_eth_by_shares(&self, shares_amount: U256) -> Result { + Self::validate_u128_bound("shares amount", shares_amount)?; + let numerator = self.internal_ether()?; + let denominator = self.internal_shares()?; + if denominator.is_zero() || numerator.is_zero() { + return Err(SimulationError::FatalError("invalid Lido share rate state".to_string())); + } + Ok((shares_amount * numerator) / denominator) + } + + fn staking_state(&self) -> Result { + self.staking_state.ok_or_else(|| { + SimulationError::FatalError("missing staking state for stETH component".to_string()) + }) + } + + fn decrease_staking_limit(&mut self, amount: U256) -> Result<(), SimulationError> { + let mut staking_state = self.staking_state()?; + staking_state.decrease(amount, self.block_number)?; + self.staking_state = Some(staking_state); + Ok(()) + } + + fn steth_spot_price(&self, base: &Token, quote: &Token) -> Result { + if base.address.as_ref() != STETH_ADDRESS || quote.address.as_ref() != ETH_ADDRESS { + return Err(SimulationError::FatalError("unsupported spot price".to_string())); + } + + let quote_unit = BigUint::from(10u32).pow(quote.decimals); + let amount_out = self + .get_amount_out(quote_unit, quote, base)? + .amount; + let base_unit_f64 = u256_to_f64(U256::from(10).pow(U256::from(base.decimals)))?; + let amount_out_f64 = amount_out.to_f64().ok_or_else(|| { + SimulationError::FatalError("failed converting spot price amount".to_string()) + })?; + let base_per_quote = amount_out_f64 / base_unit_f64; + Ok(1.0 / base_per_quote) + } + + fn wsteth_spot_price(&self, base: &Token, quote: &Token) -> Result { + let quote_unit_f64 = u256_to_f64(U256::from(10).pow(U256::from(quote.decimals)))?; + let to_price = |amount_out: U256| -> Result { + Ok(u256_to_f64(amount_out)? / quote_unit_f64) + }; + + if base.address.as_ref() == WSTETH_ADDRESS && quote.address.as_ref() == STETH_ADDRESS { + to_price(self.pooled_eth_by_shares(U256::from(10).pow(U256::from(base.decimals)))?) + } else if base.address.as_ref() == STETH_ADDRESS && quote.address.as_ref() == WSTETH_ADDRESS + { + to_price(self.shares_for_pooled_eth(U256::from(10).pow(U256::from(base.decimals)))?) + } else { + Err(SimulationError::FatalError("unsupported spot price".to_string())) + } + } + + fn amount_out_eth_to_steth( + &self, + amount_in: U256, + ) -> Result { + let shares_amount = self.shares_for_pooled_eth(amount_in)?; + let mut new_state = self.clone(); + new_state.decrease_staking_limit(amount_in)?; + new_state.total_shares += shares_amount; + new_state.buffered_ether += amount_in; + let amount_out = new_state.pooled_eth_by_shares(shares_amount)?; + Ok(GetAmountOutResult::new( + u256_to_biguint(amount_out), + BigUint::from(SUBMIT_GAS), + Box::new(new_state), + )) + } + + fn amount_out_steth_to_wsteth( + &self, + amount_in: U256, + ) -> Result { + let amount_out = self.shares_for_pooled_eth(amount_in)?; + Ok(GetAmountOutResult::new( + u256_to_biguint(amount_out), + BigUint::from(WRAP_GAS), + self.clone_box(), + )) + } + + fn amount_out_wsteth_to_steth( + &self, + amount_in: U256, + ) -> Result { + let amount_out = self.pooled_eth_by_shares(amount_in)?; + Ok(GetAmountOutResult::new( + u256_to_biguint(amount_out), + BigUint::from(UNWRAP_GAS), + self.clone_box(), + )) + } +} + +impl StakingState { + pub(crate) fn from_u256(value: U256) -> Self { + let mask_32 = U256::from(u32::MAX); + let mask_96 = (U256::from(1u8) << 96u32) - U256::ONE; + + Self { + prev_stake_block_number: (value & mask_32).to::(), + prev_stake_limit: (value >> 32u32) & mask_96, + max_stake_limit_growth_blocks: ((value >> 128u32) & mask_32).to::(), + max_stake_limit: (value >> 160u32) & mask_96, + } + } + + fn is_staking_paused(&self) -> bool { + self.prev_stake_block_number == 0 + } + + fn is_staking_limit_set(&self) -> bool { + !self.max_stake_limit.is_zero() + } + + fn calculate_current_stake_limit(&self, block_number: u64) -> U256 { + let stake_limit_inc_per_block = if self.max_stake_limit_growth_blocks != 0 { + self.max_stake_limit / U256::from(self.max_stake_limit_growth_blocks) + } else { + U256::ZERO + }; + + let blocks_passed = block_number.saturating_sub(self.prev_stake_block_number as u64); + let change = U256::from(blocks_passed) * stake_limit_inc_per_block; + + if self.prev_stake_limit < self.max_stake_limit { + (self.prev_stake_limit + change).min(self.max_stake_limit) + } else { + self.prev_stake_limit + .saturating_sub(change) + .max(self.max_stake_limit) + } + } + + fn current_limit(&self, block_number: u64) -> U256 { + if self.is_staking_paused() { + U256::ZERO + } else if !self.is_staking_limit_set() { + U256::from(UINT128_MAX_EXCLUSIVE) - U256::ONE + } else { + self.calculate_current_stake_limit(block_number) + } + } + + fn decrease(&mut self, amount: U256, block_number: u64) -> Result<(), SimulationError> { + if self.is_staking_paused() { + return Err(SimulationError::RecoverableError("STAKING_PAUSED".to_string())); + } + + if self.is_staking_limit_set() { + let current_stake_limit = self.calculate_current_stake_limit(block_number); + if amount > current_stake_limit { + return Err(SimulationError::RecoverableError("STAKE_LIMIT".to_string())); + } + self.prev_stake_limit = current_stake_limit - amount; + self.prev_stake_block_number = block_number as u32; + } + + Ok(()) + } +} + +#[typetag::serde] +impl ProtocolSim for LidoV3State { + fn fee(&self) -> f64 { + 0f64 + } + + fn spot_price(&self, base: &Token, quote: &Token) -> Result { + match self.kind { + LidoV3PoolKind::StEth => self.steth_spot_price(base, quote), + LidoV3PoolKind::WstEth => self.wsteth_spot_price(base, quote), + } + } + + fn get_amount_out( + &self, + amount_in: BigUint, + token_in: &Token, + token_out: &Token, + ) -> Result { + let amount_in = biguint_to_u256(&amount_in); + + match self.kind { + LidoV3PoolKind::StEth + if token_in.address.as_ref() == ETH_ADDRESS && + token_out.address.as_ref() == STETH_ADDRESS => + { + self.amount_out_eth_to_steth(amount_in) + } + LidoV3PoolKind::WstEth + if token_in.address.as_ref() == STETH_ADDRESS && + token_out.address.as_ref() == WSTETH_ADDRESS => + { + self.amount_out_steth_to_wsteth(amount_in) + } + LidoV3PoolKind::WstEth + if token_in.address.as_ref() == WSTETH_ADDRESS && + token_out.address.as_ref() == STETH_ADDRESS => + { + self.amount_out_wsteth_to_steth(amount_in) + } + _ => Err(SimulationError::FatalError("unsupported swap".to_string())), + } + } + + fn get_limits( + &self, + sell_token: Bytes, + buy_token: Bytes, + ) -> Result<(BigUint, BigUint), SimulationError> { + let max_input = U256::from(UINT128_MAX_EXCLUSIVE) - U256::ONE; + + match self.kind { + LidoV3PoolKind::StEth + if sell_token.as_ref() == ETH_ADDRESS && buy_token.as_ref() == STETH_ADDRESS => + { + let max_sell = self + .staking_state()? + .current_limit(self.block_number) + .min(max_input); + if max_sell.is_zero() { + return Ok((BigUint::ZERO, BigUint::ZERO)); + } + let max_buy = self + .amount_out_eth_to_steth(max_sell)? + .amount; + Ok((u256_to_biguint(max_sell), max_buy)) + } + LidoV3PoolKind::WstEth + if sell_token.as_ref() == STETH_ADDRESS && buy_token.as_ref() == WSTETH_ADDRESS => + { + Ok(( + u256_to_biguint(max_input), + u256_to_biguint(self.shares_for_pooled_eth(max_input)?), + )) + } + LidoV3PoolKind::WstEth + if sell_token.as_ref() == WSTETH_ADDRESS && buy_token.as_ref() == STETH_ADDRESS => + { + Ok(( + u256_to_biguint(max_input), + u256_to_biguint(self.pooled_eth_by_shares(max_input)?), + )) + } + _ => Err(SimulationError::FatalError("unsupported swap".to_string())), + } + } + + fn delta_transition( + &mut self, + delta: ProtocolStateDelta, + _tokens: &HashMap, + _balances: &Balances, + ) -> Result<(), TransitionError> { + if let Some(block_number) = delta + .updated_attributes + .get("block_number") + { + self.block_number = U256::from_be_slice(block_number).to::(); + } + if let Some(block_timestamp) = delta + .updated_attributes + .get("block_timestamp") + { + self.block_timestamp = U256::from_be_slice(block_timestamp).to::(); + } + if let Some(total_and_external_shares) = delta + .updated_attributes + .get(TOTAL_AND_EXTERNAL_SHARES_ATTR) + { + let (total_shares, external_shares) = + Self::split_low_high_u128(U256::from_be_slice(total_and_external_shares)); + self.total_shares = total_shares; + self.external_shares = external_shares; + } + if let Some(buffered_and_deposited) = delta + .updated_attributes + .get(BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR) + { + let (buffered_ether, deposited_validators) = + Self::split_low_high_u128(U256::from_be_slice(buffered_and_deposited)); + self.buffered_ether = buffered_ether; + self.deposited_validators = deposited_validators; + } + if let Some(cl_balance_and_validators) = delta + .updated_attributes + .get(CL_BALANCE_AND_CL_VALIDATORS_ATTR) + { + let (cl_balance, cl_validators) = + Self::split_low_high_u128(U256::from_be_slice(cl_balance_and_validators)); + self.cl_balance = cl_balance; + self.cl_validators = cl_validators; + } + if let Some(staking_state) = delta + .updated_attributes + .get(STAKING_STATE_ATTR) + { + self.staking_state = Some(StakingState::from_u256(U256::from_be_slice(staking_state))); + } + Ok(()) + } + + fn query_pool_swap( + &self, + params: &tycho_common::simulation::protocol_sim::QueryPoolSwapParams, + ) -> Result { + crate::evm::query_pool_swap::query_pool_swap(self, params) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn eq(&self, other: &dyn ProtocolSim) -> bool { + other.as_any().downcast_ref::() == Some(self) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tycho_client::feed::BlockHeader; + use tycho_common::{ + dto::{ProtocolComponent, ProtocolStateDelta, ResponseProtocolState}, + models::Chain, + simulation::errors::SimulationError, + Bytes, + }; + + use super::*; + use crate::{ + evm::protocol::test_utils::try_decode_snapshot_with_defaults, + protocol::models::TryFromWithBlock, + }; + + fn eth_token() -> Token { + Token::new(&Bytes::from(ETH_ADDRESS), "ETH", 18, 0, &[], Chain::Ethereum, 100) + } + + fn steth_token() -> Token { + Token::new(&Bytes::from(STETH_ADDRESS), "stETH", 18, 0, &[], Chain::Ethereum, 75) + } + + fn wsteth_token() -> Token { + Token::new(&Bytes::from(WSTETH_ADDRESS), "wstETH", 18, 0, &[], Chain::Ethereum, 100) + } + + fn sample_staking_state() -> StakingState { + StakingState { + prev_stake_block_number: 24_083_113, + prev_stake_limit: U256::from(1_000u64) * U256::from(10).pow(U256::from(18)), + max_stake_limit_growth_blocks: 10, + max_stake_limit: U256::from(1_000u64) * U256::from(10).pow(U256::from(18)), + } + } + + fn sample_steth_state() -> LidoV3State { + LidoV3State::new( + LidoV3PoolKind::StEth, + 24_083_113, + 1_744_791_234, + U256::from_str_radix("6696604823358181328750512", 10).unwrap(), + U256::from_str_radix("80758346894447149184", 10).unwrap(), + U256::from_str_radix("658338852056838456032283", 10).unwrap(), + U256::from(413_700u64), + U256::from_str_radix("21114116614166341429013364", 10).unwrap(), + U256::from(412_745u64), + Some(sample_staking_state()), + ) + } + + fn sample_wsteth_state() -> LidoV3State { + let mut state = sample_steth_state(); + state.kind = LidoV3PoolKind::WstEth; + state.staking_state = None; + state + } + + fn staking_state_raw(state: StakingState) -> U256 { + U256::from(state.prev_stake_block_number) | + (state.prev_stake_limit << 32u32) | + (U256::from(state.max_stake_limit_growth_blocks) << 128u32) | + (state.max_stake_limit << 160u32) + } + + fn snapshot_for(kind: LidoV3PoolKind) -> tycho_client::feed::synchronizer::ComponentWithState { + let state = sample_steth_state(); + let mut attributes = HashMap::from([ + ( + TOTAL_AND_EXTERNAL_SHARES_ATTR.to_string(), + Bytes::from( + (state.total_shares | (state.external_shares << 128u32)).to_be_bytes_vec(), + ), + ), + ( + BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR.to_string(), + Bytes::from( + (state.buffered_ether | (state.deposited_validators << 128u32)) + .to_be_bytes_vec(), + ), + ), + ( + CL_BALANCE_AND_CL_VALIDATORS_ATTR.to_string(), + Bytes::from((state.cl_balance | (state.cl_validators << 128u32)).to_be_bytes_vec()), + ), + ]); + let component_id = match kind { + LidoV3PoolKind::StEth => { + attributes.insert( + STAKING_STATE_ATTR.to_string(), + Bytes::from(staking_state_raw(sample_staking_state()).to_be_bytes_vec()), + ); + STETH_COMPONENT_ID.to_string() + } + LidoV3PoolKind::WstEth => WSTETH_COMPONENT_ID.to_string(), + }; + + tycho_client::feed::synchronizer::ComponentWithState { + state: ResponseProtocolState { + component_id: component_id.clone(), + attributes, + balances: HashMap::new(), + }, + component: ProtocolComponent { + id: component_id, + protocol_system: "lido_v3".to_string(), + protocol_type_name: "lido_v3_pool".to_string(), + chain: Chain::Ethereum.into(), + tokens: Vec::new(), + contract_ids: Vec::new(), + static_attributes: HashMap::new(), + change: Default::default(), + creation_tx: Bytes::new(), + created_at: chrono::DateTime::UNIX_EPOCH.naive_utc(), + }, + component_tvl: None, + entrypoints: Vec::new(), + } + } + + #[test] + fn split_low_high_u128_decodes_packed_slots() { + let low = U256::from(123u64); + let high = U256::from(456u64); + let packed = low | (high << 128u32); + let decoded = LidoV3State::split_low_high_u128(packed); + assert_eq!(decoded, (low, high)); + } + + #[test] + fn staking_state_from_u256_decodes_fields() { + let raw = staking_state_raw(sample_staking_state()); + let decoded = StakingState::from_u256(raw); + assert_eq!(decoded, sample_staking_state()); + } + + #[tokio::test] + async fn decoder_reads_steth_snapshot() { + let state = + try_decode_snapshot_with_defaults::(snapshot_for(LidoV3PoolKind::StEth)) + .await + .unwrap(); + + assert_eq!(state.kind, LidoV3PoolKind::StEth); + assert!(state.staking_state.is_some()); + assert_eq!(state.total_shares, sample_steth_state().total_shares); + } + + #[tokio::test] + async fn decoder_reads_wsteth_snapshot() { + let state = + try_decode_snapshot_with_defaults::(snapshot_for(LidoV3PoolKind::WstEth)) + .await + .unwrap(); + + assert_eq!(state.kind, LidoV3PoolKind::WstEth); + assert!(state.staking_state.is_none()); + assert_eq!(state.buffered_ether, sample_steth_state().buffered_ether); + } + + #[test] + fn eth_to_steth_updates_state_and_consumes_stake_limit() { + let state = sample_steth_state(); + let amount_in = BigUint::from(10u64).pow(18); + let result = state + .get_amount_out(amount_in.clone(), ð_token(), &steth_token()) + .unwrap(); + + assert!(result.amount > BigUint::ZERO); + let new_state = result + .new_state + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!( + new_state.buffered_ether, + state.buffered_ether + U256::from(10).pow(U256::from(18)) + ); + assert!(new_state.total_shares > state.total_shares); + let old_limit = state + .staking_state + .unwrap() + .current_limit(state.block_number); + let new_limit = new_state + .staking_state + .unwrap() + .current_limit(new_state.block_number); + assert!(new_limit < old_limit); + } + + #[test] + fn steth_to_wsteth_and_back_keeps_state_constant() { + let state = sample_wsteth_state(); + let amount_in = BigUint::from(10u64).pow(18); + + let wrap = state + .get_amount_out(amount_in.clone(), &steth_token(), &wsteth_token()) + .unwrap(); + let wrapped_state = wrap + .new_state + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(wrapped_state, &state); + + let unwrap = state + .get_amount_out(amount_in, &wsteth_token(), &steth_token()) + .unwrap(); + let unwrapped_state = unwrap + .new_state + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(unwrapped_state, &state); + assert!(unwrap.amount > BigUint::ZERO); + } + + #[test] + fn get_limits_respects_current_stake_limit() { + let state = sample_steth_state(); + let (max_in, max_out) = state + .get_limits(Bytes::from(ETH_ADDRESS), Bytes::from(STETH_ADDRESS)) + .unwrap(); + + assert_eq!( + max_in, + u256_to_biguint( + state + .staking_state + .unwrap() + .current_limit(state.block_number) + ) + ); + assert!(max_out > BigUint::ZERO); + } + + #[test] + fn paused_staking_blocks_eth_to_steth() { + let mut state = sample_steth_state(); + let mut staking_state = state.staking_state.unwrap(); + staking_state.prev_stake_block_number = 0; + state.staking_state = Some(staking_state); + + let err = state + .get_amount_out(BigUint::from(10u64).pow(18), ð_token(), &steth_token()) + .unwrap_err(); + + assert!( + matches!(err, SimulationError::RecoverableError(ref msg) if msg == "STAKING_PAUSED") + ); + } + + #[test] + fn delta_transition_updates_state() { + let mut state = sample_steth_state(); + let new_total = U256::from(999u64); + let new_external = U256::from(111u64); + let new_buffered = U256::from(222u64); + let new_deposited = U256::from(333u64); + let new_cl_balance = U256::from(444u64); + let new_cl_validators = U256::from(555u64); + let new_staking_state = StakingState { + prev_stake_block_number: 77, + prev_stake_limit: U256::from(888u64), + max_stake_limit_growth_blocks: 9, + max_stake_limit: U256::from(999u64), + }; + + state + .delta_transition( + ProtocolStateDelta { + component_id: STETH_COMPONENT_ID.to_string(), + updated_attributes: HashMap::from([ + ("block_number".to_string(), Bytes::from(88u64.to_be_bytes().to_vec())), + ("block_timestamp".to_string(), Bytes::from(99u64.to_be_bytes().to_vec())), + ( + TOTAL_AND_EXTERNAL_SHARES_ATTR.to_string(), + Bytes::from((new_total | (new_external << 128u32)).to_be_bytes_vec()), + ), + ( + BUFFERED_ETHER_AND_DEPOSITED_VALIDATORS_ATTR.to_string(), + Bytes::from( + (new_buffered | (new_deposited << 128u32)).to_be_bytes_vec(), + ), + ), + ( + CL_BALANCE_AND_CL_VALIDATORS_ATTR.to_string(), + Bytes::from( + (new_cl_balance | (new_cl_validators << 128u32)).to_be_bytes_vec(), + ), + ), + ( + STAKING_STATE_ATTR.to_string(), + Bytes::from(staking_state_raw(new_staking_state).to_be_bytes_vec()), + ), + ]), + deleted_attributes: Default::default(), + }, + &HashMap::new(), + &Balances::default(), + ) + .unwrap(); + + assert_eq!(state.block_number, 88); + assert_eq!(state.block_timestamp, 99); + assert_eq!(state.total_shares, new_total); + assert_eq!(state.external_shares, new_external); + assert_eq!(state.buffered_ether, new_buffered); + assert_eq!(state.deposited_validators, new_deposited); + assert_eq!(state.cl_balance, new_cl_balance); + assert_eq!(state.cl_validators, new_cl_validators); + assert_eq!(state.staking_state, Some(new_staking_state)); + } + + #[test] + fn unsupported_direction_errors() { + let err = sample_steth_state() + .get_amount_out(BigUint::from(10u64).pow(18), &steth_token(), ð_token()) + .unwrap_err(); + assert!(matches!(err, SimulationError::FatalError(_))); + } + + #[tokio::test] + async fn decoder_uses_header_block_info() { + let snapshot = snapshot_for(LidoV3PoolKind::StEth); + let state = LidoV3State::try_from_with_header( + snapshot, + BlockHeader { + number: 123, + timestamp: 456, + hash: Bytes::new(), + parent_hash: Bytes::new(), + revert: false, + partial_block_index: None, + }, + &HashMap::new(), + &HashMap::new(), + &Default::default(), + ) + .await + .unwrap(); + + assert_eq!(state.block_number, 123); + assert_eq!(state.block_timestamp, 456); + } +} diff --git a/crates/tycho-simulation/src/evm/protocol/mod.rs b/crates/tycho-simulation/src/evm/protocol/mod.rs index c5d66413d6..1fef96733a 100644 --- a/crates/tycho-simulation/src/evm/protocol/mod.rs +++ b/crates/tycho-simulation/src/evm/protocol/mod.rs @@ -8,6 +8,7 @@ pub mod erc4626; pub mod etherfi; pub mod filters; pub mod fluid; +pub mod lido_v3; pub mod pancakeswap_v2; pub mod rocketpool; pub mod safe_math; @@ -18,7 +19,6 @@ pub mod uniswap_v4; pub mod utils; pub mod velodrome_slipstreams; pub mod vm; - #[cfg(test)] mod test_utils { use std::collections::HashMap; From bba81bd14bda67843f6788061225b61869cff5d4 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 22 Apr 2026 17:12:28 +0800 Subject: [PATCH 5/6] feat(tycho-execution): add lido v3 execution support --- crates/tycho-execution/Cargo.toml | 2 +- .../config/executor_addresses.json | 2 +- .../config/protocol_specific_addresses.json | 6 +- .../config/test_executor_addresses.json | 1 + .../contracts/scripts/deploy-executors.js | 7 + .../src/executors/LidoV3Executor.sol | 119 +++++++ .../contracts/test/TychoRouterTestSetup.sol | 6 +- .../contracts/test/assets/calldata.txt | 4 + .../contracts/test/protocols/LidoV3.t.sol | 305 ++++++++++++++++++ .../src/encoding/evm/swap_encoder/lido_v3.rs | 201 ++++++++++++ .../src/encoding/evm/swap_encoder/mod.rs | 1 + .../evm/swap_encoder/swap_encoder_registry.rs | 14 +- .../tests/protocol_integration_tests.rs | 184 +++++++++++ 13 files changed, 843 insertions(+), 9 deletions(-) create mode 100644 crates/tycho-execution/contracts/src/executors/LidoV3Executor.sol create mode 100644 crates/tycho-execution/contracts/test/protocols/LidoV3.t.sol create mode 100644 crates/tycho-execution/src/encoding/evm/swap_encoder/lido_v3.rs diff --git a/crates/tycho-execution/Cargo.toml b/crates/tycho-execution/Cargo.toml index 55b64f079a..189bd64f43 100644 --- a/crates/tycho-execution/Cargo.toml +++ b/crates/tycho-execution/Cargo.toml @@ -38,7 +38,7 @@ thiserror = { workspace = true } tokio = { workspace = true } tycho-common = { workspace = true } typetag = { workspace = true, optional = true } -alloy = { workspace = true, features = ["providers", "rpc-types-eth", "eip712", "signer-local", "node-bindings"], optional = true } +alloy = { workspace = true, features = ["providers", "rpc-types-eth", "eip712", "signer-local", "node-bindings", "sol-types"], optional = true } async-trait = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/tycho-execution/config/executor_addresses.json b/crates/tycho-execution/config/executor_addresses.json index 38f0932cc7..590b4e7085 100644 --- a/crates/tycho-execution/config/executor_addresses.json +++ b/crates/tycho-execution/config/executor_addresses.json @@ -37,4 +37,4 @@ "vm:curve": "0xbc4d9e944ad40480a34ebaf38cd2acf6e1dc0def", "weth": "0x13AcF9532C173753484ed1Bef86C98cBfe83Ba90" } -} \ No newline at end of file +} diff --git a/crates/tycho-execution/config/protocol_specific_addresses.json b/crates/tycho-execution/config/protocol_specific_addresses.json index aa4ec85b3e..c49bf10593 100644 --- a/crates/tycho-execution/config/protocol_specific_addresses.json +++ b/crates/tycho-execution/config/protocol_specific_addresses.json @@ -10,6 +10,10 @@ "eeth_address": "0x35fA164735182de50811E8e2E824cFb9B6118ac2", "weeth_address": "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee" }, + "lido_v3": { + "steth_address": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", + "wsteth_address": "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" + }, "rfq:liquorice": { "balance_manager_address": "0xb87bAE43a665EB5943A5642F81B26666bC9E5C95" } @@ -20,4 +24,4 @@ "native_token_address": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" } } -} \ No newline at end of file +} diff --git a/crates/tycho-execution/config/test_executor_addresses.json b/crates/tycho-execution/config/test_executor_addresses.json index 5c5d18fefd..a9bf64781f 100644 --- a/crates/tycho-execution/config/test_executor_addresses.json +++ b/crates/tycho-execution/config/test_executor_addresses.json @@ -19,6 +19,7 @@ "weth": "0x96d3F6c20EEd2697647F543fE6C08bC2Fbf39758", "ekubo_v3": "0x13aa49bAc059d709dd0a18D6bb63290076a702D7", "etherfi": "0xDB25A7b768311dE128BBDa7B8426c3f9C74f3240", + "lido_v3": "0xe8dc788818033232EF9772CB2e6622F1Ec8bc840", "rfq:liquorice": "0x3381cD18e2Fb4dB236BF0525938AB6E43Db0440f" }, "base": { diff --git a/crates/tycho-execution/contracts/scripts/deploy-executors.js b/crates/tycho-execution/contracts/scripts/deploy-executors.js index f15776b096..1f3db6322d 100644 --- a/crates/tycho-execution/contracts/scripts/deploy-executors.js +++ b/crates/tycho-execution/contracts/scripts/deploy-executors.js @@ -72,6 +72,13 @@ const executors_to_deploy = { { exchange: "WethExecutor", args: ["0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"] }, + // Args: stETH address, wstETH address + { + exchange: "LidoV3Executor", args: [ + "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", + "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" + ] + }, // Args: Liquorice settlement, Liquorice balance manager { diff --git a/crates/tycho-execution/contracts/src/executors/LidoV3Executor.sol b/crates/tycho-execution/contracts/src/executors/LidoV3Executor.sol new file mode 100644 index 0000000000..1c9a599aee --- /dev/null +++ b/crates/tycho-execution/contracts/src/executors/LidoV3Executor.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import {IExecutor} from "@interfaces/IExecutor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {TransferManager} from "../TransferManager.sol"; + +error LidoV3Executor__InvalidDataLength(); +error LidoV3Executor__InvalidDirection(); +error LidoV3Executor__ZeroAddress(); + +interface IStETH is IERC20 { + function submit(address referral) external payable returns (uint256); +} + +interface IWstETH is IERC20 { + function wrap(uint256 stETHAmount) external returns (uint256); + function unwrap(uint256 wstETHAmount) external returns (uint256); +} + +enum LidoV3Direction { + EthToStEth, + StEthToWstEth, + WstEthToStEth +} + +contract LidoV3Executor is IExecutor { + IStETH public immutable stEth; + IWstETH public immutable wstEth; + + constructor(address stEthAddress, address wstEthAddress) { + if (stEthAddress == address(0) || wstEthAddress == address(0)) { + revert LidoV3Executor__ZeroAddress(); + } + + stEth = IStETH(stEthAddress); + wstEth = IWstETH(wstEthAddress); + } + + function fundsExpectedAddress( + bytes calldata /* data */ + ) + external + view + returns (address receiver) + { + return msg.sender; + } + + // slither-disable-next-line locked-ether + function swap(uint256 amountIn, bytes calldata data, address /* receiver */) + external + payable + { + LidoV3Direction direction = _decodeData(data); + + if (direction == LidoV3Direction.EthToStEth) { + stEth.submit{value: amountIn}(address(0)); + } else if (direction == LidoV3Direction.StEthToWstEth) { + wstEth.wrap(amountIn); + } else if (direction == LidoV3Direction.WstEthToStEth) { + wstEth.unwrap(amountIn); + } else { + revert LidoV3Executor__InvalidDirection(); + } + } + + function getTransferData(bytes calldata data) + external + payable + returns ( + TransferManager.TransferType transferType, + address receiver, + address tokenIn, + address tokenOut, + bool outputToRouter + ) + { + LidoV3Direction direction = _decodeData(data); + + if (direction == LidoV3Direction.EthToStEth) { + transferType = TransferManager.TransferType.TransferNativeInExecutor; + receiver = msg.sender; + tokenIn = address(0); + tokenOut = address(stEth); + } else if (direction == LidoV3Direction.StEthToWstEth) { + transferType = TransferManager.TransferType.ProtocolWillDebit; + receiver = address(wstEth); + tokenIn = address(stEth); + tokenOut = address(wstEth); + } else if (direction == LidoV3Direction.WstEthToStEth) { + transferType = TransferManager.TransferType.ProtocolWillDebit; + receiver = msg.sender; + tokenIn = address(wstEth); + tokenOut = address(stEth); + } else { + revert LidoV3Executor__InvalidDirection(); + } + + outputToRouter = true; + } + + function _decodeData(bytes calldata data) + internal + pure + returns (LidoV3Direction direction) + { + if (data.length != 1) { + revert LidoV3Executor__InvalidDataLength(); + } + + uint8 rawDirection = uint8(data[0]); + if (rawDirection > uint8(LidoV3Direction.WstEthToStEth)) { + revert LidoV3Executor__InvalidDirection(); + } + + direction = LidoV3Direction(rawDirection); + } +} diff --git a/crates/tycho-execution/contracts/test/TychoRouterTestSetup.sol b/crates/tycho-execution/contracts/test/TychoRouterTestSetup.sol index 16790d466c..cfa002fed1 100644 --- a/crates/tycho-execution/contracts/test/TychoRouterTestSetup.sol +++ b/crates/tycho-execution/contracts/test/TychoRouterTestSetup.sol @@ -23,6 +23,7 @@ import {FluidV1Executor} from "../src/executors/FluidV1Executor.sol"; import {SlipstreamsExecutor} from "../src/executors/SlipstreamsExecutor.sol"; import {RocketpoolExecutor} from "../src/executors/RocketpoolExecutor.sol"; import {ERC4626Executor} from "../src/executors/ERC4626Executor.sol"; +import {LidoV3Executor} from "../src/executors/LidoV3Executor.sol"; import {WethExecutor} from "../src/executors/WethExecutor.sol"; import {LiquoriceExecutor} from "../src/executors/LiquoriceExecutor.sol"; import {AerodromeV1Executor} from "../src/executors/AerodromeV1Executor.sol"; @@ -112,6 +113,7 @@ contract TychoRouterTestSetup is RocketpoolExecutor public rocketpoolExecutor; ERC4626Executor public erc4626Executor; WethExecutor public wethExecutor; + LidoV3Executor public lidoV3Executor; EkuboV3Executor public ekuboV3Executor; EtherfiExecutor public etherfiExecutor; LiquidityPartyExecutor public liquidityPartyExecutor; @@ -213,8 +215,9 @@ contract TychoRouterTestSetup is ); liquidityPartyExecutor = new LiquidityPartyExecutor(); aerodromeV1Executor = new AerodromeV1Executor(); + lidoV3Executor = new LidoV3Executor(STETH_ADDR, WSTETH_ADDR); - address[] memory executors = new address[](21); + address[] memory executors = new address[](22); executors[0] = address(usv2Executor); executors[1] = address(usv3Executor); executors[2] = address(pancakev3Executor); @@ -236,6 +239,7 @@ contract TychoRouterTestSetup is executors[18] = address(liquoriceExecutor); executors[19] = address(liquidityPartyExecutor); executors[20] = address(aerodromeV1Executor); + executors[21] = address(lidoV3Executor); return executors; } diff --git a/crates/tycho-execution/contracts/test/assets/calldata.txt b/crates/tycho-execution/contracts/test/assets/calldata.txt index 62a64a8682..4f5b6bf94a 100644 --- a/crates/tycho-execution/contracts/test/assets/calldata.txt +++ b/crates/tycho-execution/contracts/test/assets/calldata.txt @@ -82,3 +82,7 @@ test_two_hop_usv4_twif_intermediary:cd914fde000000000000000000000000000000000000 test_encode_aerodrome_v1:723aef6543aece026a15662be4d3fb3424d502a9236aa50979d5f3de3bd1eeb40e81137f22ab794bd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca01 test_single_encoding_strategy_aerodrome_v1:ce25e49e000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000236aa50979d5f3de3bd1eeb40e81137f22ab794b000000000000000000000000d9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca00000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000511af7f588a501ea2b5bb3feefa744892aa2cf00e6723aef6543aece026a15662be4d3fb3424d502a9236aa50979d5f3de3bd1eeb40e81137f22ab794bd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca01000000000000000000000000000000 test_sequential_encoding_strategy_aerodrome_v1:6fc8683a0000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000004621b7a9c75199271f773ebd9a499dbd165c3191000000000000000000000000236aa50979d5f3de3bd1eeb40e81137f22ab794b00000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a600511af7f588a501ea2b5bb3feefa744892aa2cf00e60b25c51637c43decd6cc1c1e3da4518d54ddb5284621b7a9c75199271f773ebd9a499dbd165c3191d9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca0100511af7f588a501ea2b5bb3feefa744892aa2cf00e6723aef6543aece026a15662be4d3fb3424d502a9d9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca236aa50979d5f3de3bd1eeb40e81137f22ab794b000000000000000000000000000000000000000000000000000000 +test_single_encoding_strategy_lido_v3_wrap:ce25e49e0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000ae7ab96520de3a18e5e111b5eaab095312d7fe840000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca00000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015e8dc788818033232ef9772cb2e6622f1ec8bc840010000000000000000000000 +test_sequential_encoding_strategy_lido_v3_submit_then_wrap:6fc8683a0000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca00000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e0015e8dc788818033232ef9772cb2e6622f1ec8bc840000015e8dc788818033232ef9772cb2e6622f1ec8bc84001000000000000000000000000000000000000 +test_single_encoding_strategy_lido_v3_unwrap:ce25e49e0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca0000000000000000000000000ae7ab96520de3a18e5e111b5eaab095312d7fe840000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015e8dc788818033232ef9772cb2e6622f1ec8bc840020000000000000000000000 +test_single_encoding_strategy_lido_v3_submit:ce25e49e0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ae7ab96520de3a18e5e111b5eaab095312d7fe840000000000000000000000000000000000000000000000000c7d713b49da0000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015e8dc788818033232ef9772cb2e6622f1ec8bc840000000000000000000000000 diff --git a/crates/tycho-execution/contracts/test/protocols/LidoV3.t.sol b/crates/tycho-execution/contracts/test/protocols/LidoV3.t.sol new file mode 100644 index 0000000000..a3a49e973c --- /dev/null +++ b/crates/tycho-execution/contracts/test/protocols/LidoV3.t.sol @@ -0,0 +1,305 @@ +pragma solidity ^0.8.26; + +import "../TychoRouterTestSetup.sol"; +import {Constants} from "../Constants.sol"; +import {TransferManager} from "../../src/TransferManager.sol"; +import { + LidoV3Executor, + LidoV3Executor__InvalidDataLength, + LidoV3Executor__InvalidDirection, + IStETH, + IWstETH, + LidoV3Direction +} from "../../src/executors/LidoV3Executor.sol"; +import {TestUtils} from "../TestUtils.sol"; + +contract LidoV3ExecutorExposed is LidoV3Executor { + constructor(address stEthAddress, address wstEthAddress) + LidoV3Executor(stEthAddress, wstEthAddress) + {} + + function decodeParams(bytes calldata data) + external + pure + returns (LidoV3Direction direction) + { + return _decodeData(data); + } +} + +contract LidoV3ExecutorTest is TestUtils, Constants { + LidoV3ExecutorExposed lidoV3Executor; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), 24480104); + lidoV3Executor = new LidoV3ExecutorExposed(STETH_ADDR, WSTETH_ADDR); + } + + function _mintStEthToExecutor(uint256 depositAmount) + internal + returns (uint256 minted) + { + bytes memory submitData = + abi.encodePacked(uint8(LidoV3Direction.EthToStEth)); + + vm.deal(address(this), depositAmount); + uint256 balanceBefore = + IERC20(STETH_ADDR).balanceOf(address(lidoV3Executor)); + + lidoV3Executor.swap{value: depositAmount}( + depositAmount, submitData, address(lidoV3Executor) + ); + + uint256 balanceAfter = + IERC20(STETH_ADDR).balanceOf(address(lidoV3Executor)); + minted = balanceAfter - balanceBefore; + } + + function testDecodeParamsSubmit() public view { + bytes memory params = abi.encodePacked(uint8(LidoV3Direction.EthToStEth)); + LidoV3Direction direction = lidoV3Executor.decodeParams(params); + + assertEq(uint8(direction), uint8(LidoV3Direction.EthToStEth)); + } + + function testDecodeParamsWrap() public view { + bytes memory params = + abi.encodePacked(uint8(LidoV3Direction.StEthToWstEth)); + LidoV3Direction direction = lidoV3Executor.decodeParams(params); + + assertEq(uint8(direction), uint8(LidoV3Direction.StEthToWstEth)); + } + + function testDecodeParamsUnwrap() public view { + bytes memory params = + abi.encodePacked(uint8(LidoV3Direction.WstEthToStEth)); + LidoV3Direction direction = lidoV3Executor.decodeParams(params); + + assertEq(uint8(direction), uint8(LidoV3Direction.WstEthToStEth)); + } + + function testDecodeParamsInvalidDataLength() public { + bytes memory invalidParams = + abi.encodePacked(uint8(LidoV3Direction.EthToStEth), uint8(1)); + + vm.expectRevert(LidoV3Executor__InvalidDataLength.selector); + lidoV3Executor.decodeParams(invalidParams); + } + + function testDecodeParamsInvalidDirection() public { + bytes memory invalidParams = abi.encodePacked(uint8(3)); + + vm.expectRevert(LidoV3Executor__InvalidDirection.selector); + lidoV3Executor.decodeParams(invalidParams); + } + + function testGetTransferDataSubmit() public { + bytes memory params = abi.encodePacked(uint8(LidoV3Direction.EthToStEth)); + + ( + TransferManager.TransferType transferType, + address receiver, + address tokenIn, + address tokenOut, + bool outputToRouter + ) = lidoV3Executor.getTransferData(params); + + assertEq( + uint8(transferType), + uint8(TransferManager.TransferType.TransferNativeInExecutor) + ); + assertEq(receiver, address(this)); + assertEq(tokenIn, address(0)); + assertEq(tokenOut, STETH_ADDR); + assertEq(outputToRouter, true); + } + + function testGetTransferDataWrap() public { + bytes memory params = + abi.encodePacked(uint8(LidoV3Direction.StEthToWstEth)); + + ( + TransferManager.TransferType transferType, + address receiver, + address tokenIn, + address tokenOut, + bool outputToRouter + ) = lidoV3Executor.getTransferData(params); + + assertEq( + uint8(transferType), + uint8(TransferManager.TransferType.ProtocolWillDebit) + ); + assertEq(receiver, WSTETH_ADDR); + assertEq(tokenIn, STETH_ADDR); + assertEq(tokenOut, WSTETH_ADDR); + assertEq(outputToRouter, true); + } + + function testGetTransferDataUnwrap() public { + bytes memory params = + abi.encodePacked(uint8(LidoV3Direction.WstEthToStEth)); + + ( + TransferManager.TransferType transferType, + address receiver, + address tokenIn, + address tokenOut, + bool outputToRouter + ) = lidoV3Executor.getTransferData(params); + + assertEq( + uint8(transferType), + uint8(TransferManager.TransferType.ProtocolWillDebit) + ); + assertEq(receiver, address(this)); + assertEq(tokenIn, WSTETH_ADDR); + assertEq(tokenOut, STETH_ADDR); + assertEq(outputToRouter, true); + } + + function testSwapSubmit() public { + uint256 amountIn = 1 ether; + bytes memory protocolData = + abi.encodePacked(uint8(LidoV3Direction.EthToStEth)); + + vm.deal(address(this), amountIn); + uint256 balanceBefore = IERC20(STETH_ADDR).balanceOf(address(lidoV3Executor)); + + lidoV3Executor.swap{value: amountIn}(amountIn, protocolData, BOB); + + uint256 balanceAfter = IERC20(STETH_ADDR).balanceOf(address(lidoV3Executor)); + assertGt(balanceAfter, balanceBefore); + } + + function testSwapWrap() public { + uint256 amountIn = _mintStEthToExecutor(1 ether); + bytes memory protocolData = + abi.encodePacked(uint8(LidoV3Direction.StEthToWstEth)); + + vm.prank(address(lidoV3Executor)); + IERC20(STETH_ADDR).approve(WSTETH_ADDR, amountIn); + + uint256 balanceBefore = IERC20(WSTETH_ADDR).balanceOf(address(lidoV3Executor)); + + lidoV3Executor.swap(amountIn, protocolData, BOB); + + uint256 balanceAfter = IERC20(WSTETH_ADDR).balanceOf(address(lidoV3Executor)); + assertGt(balanceAfter, balanceBefore); + } + + function testSwapUnwrap() public { + uint256 amountIn = 1 ether; + bytes memory protocolData = + abi.encodePacked(uint8(LidoV3Direction.WstEthToStEth)); + + deal(WSTETH_ADDR, address(lidoV3Executor), amountIn); + + uint256 balanceBefore = IERC20(STETH_ADDR).balanceOf(address(lidoV3Executor)); + + lidoV3Executor.swap(amountIn, protocolData, BOB); + + uint256 balanceAfter = IERC20(STETH_ADDR).balanceOf(address(lidoV3Executor)); + assertGt(balanceAfter, balanceBefore); + } +} + +contract TychoRouterForLidoV3Test is TychoRouterTestSetup { + function getForkBlock() public pure override returns (uint256) { + return 24480104; + } + + function _mintStEthTo(address recipient, uint256 depositAmount) + internal + returns (uint256 minted) + { + uint256 balanceBefore = IERC20(STETH_ADDR).balanceOf(recipient); + + vm.deal(recipient, depositAmount); + vm.prank(recipient); + IStETH(STETH_ADDR).submit{value: depositAmount}(address(0)); + + uint256 balanceAfter = IERC20(STETH_ADDR).balanceOf(recipient); + minted = balanceAfter - balanceBefore; + } + + function testSingleLidoV3SubmitIntegration() public { + IERC20 stEth = IERC20(STETH_ADDR); + uint256 amountIn = 1 ether; + bytes memory callData = + loadCallDataFromFile("test_single_encoding_strategy_lido_v3_submit"); + + vm.deal(ALICE, amountIn); + vm.startPrank(ALICE); + + uint256 balanceBefore = stEth.balanceOf(ALICE); + (bool success,) = tychoRouterAddr.call{value: amountIn}(callData); + uint256 balanceAfter = stEth.balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertGt(balanceAfter, balanceBefore); + assertLe(stEth.balanceOf(tychoRouterAddr), 1); + assertEq(tychoRouterAddr.balance, 0); + } + + function testSingleLidoV3WrapIntegration() public { + IERC20 wstEth = IERC20(WSTETH_ADDR); + uint256 amountIn = 1 ether; + bytes memory callData = + loadCallDataFromFile("test_single_encoding_strategy_lido_v3_wrap"); + + _mintStEthTo(ALICE, 2 ether); + vm.startPrank(ALICE); + IERC20(STETH_ADDR).approve(tychoRouterAddr, amountIn); + + uint256 balanceBefore = wstEth.balanceOf(ALICE); + (bool success,) = tychoRouterAddr.call(callData); + uint256 balanceAfter = wstEth.balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertGt(balanceAfter, balanceBefore); + assertLe(IERC20(STETH_ADDR).balanceOf(tychoRouterAddr), 1); + assertEq(wstEth.balanceOf(tychoRouterAddr), 0); + } + + function testSingleLidoV3UnwrapIntegration() public { + IERC20 stEth = IERC20(STETH_ADDR); + uint256 amountIn = 1 ether; + bytes memory callData = + loadCallDataFromFile("test_single_encoding_strategy_lido_v3_unwrap"); + + deal(WSTETH_ADDR, ALICE, amountIn); + vm.startPrank(ALICE); + IERC20(WSTETH_ADDR).approve(tychoRouterAddr, amountIn); + + uint256 balanceBefore = stEth.balanceOf(ALICE); + (bool success,) = tychoRouterAddr.call(callData); + uint256 balanceAfter = stEth.balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertGt(balanceAfter, balanceBefore); + assertEq(IERC20(WSTETH_ADDR).balanceOf(tychoRouterAddr), 0); + assertLe(stEth.balanceOf(tychoRouterAddr), 1); + } + + function testSequentialLidoV3SubmitThenWrapIntegration() public { + IERC20 wstEth = IERC20(WSTETH_ADDR); + uint256 amountIn = 1 ether; + bytes memory callData = loadCallDataFromFile( + "test_sequential_encoding_strategy_lido_v3_submit_then_wrap" + ); + + vm.deal(ALICE, amountIn); + vm.startPrank(ALICE); + + uint256 balanceBefore = wstEth.balanceOf(ALICE); + (bool success,) = tychoRouterAddr.call{value: amountIn}(callData); + uint256 balanceAfter = wstEth.balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertGt(balanceAfter, balanceBefore); + assertLe(IERC20(STETH_ADDR).balanceOf(tychoRouterAddr), 1); + assertEq(wstEth.balanceOf(tychoRouterAddr), 0); + assertEq(tychoRouterAddr.balance, 0); + } +} diff --git a/crates/tycho-execution/src/encoding/evm/swap_encoder/lido_v3.rs b/crates/tycho-execution/src/encoding/evm/swap_encoder/lido_v3.rs new file mode 100644 index 0000000000..a7db010207 --- /dev/null +++ b/crates/tycho-execution/src/encoding/evm/swap_encoder/lido_v3.rs @@ -0,0 +1,201 @@ +use std::collections::HashMap; + +use alloy::sol_types::SolValue; +use tycho_common::{models::Chain, Bytes}; + +use crate::encoding::{ + errors::EncodingError, + models::{EncodingContext, Swap}, + swap_encoder::SwapEncoder, +}; + +#[derive(Clone)] +pub struct LidoV3SwapEncoder { + executor_address: Bytes, + steth_address: Bytes, + wsteth_address: Bytes, + native_token_address: Bytes, +} + +#[repr(u8)] +enum LidoV3Direction { + Submit = 0, + Wrap = 1, + Unwrap = 2, +} + +impl SwapEncoder for LidoV3SwapEncoder { + fn new( + executor_address: Bytes, + chain: Chain, + config: Option>, + ) -> Result { + let config = config + .ok_or_else(|| EncodingError::FatalError("Lido V3 config is empty".to_string()))?; + + let steth_address = config + .get("steth_address") + .map(|a| Bytes::from(a.as_str())) + .ok_or_else(|| { + EncodingError::FatalError("Missing steth_address in lido_v3 config".to_string()) + })?; + + let wsteth_address = config + .get("wsteth_address") + .map(|a| Bytes::from(a.as_str())) + .ok_or_else(|| { + EncodingError::FatalError("Missing wsteth_address in lido_v3 config".to_string()) + })?; + + Ok(Self { + executor_address, + steth_address, + wsteth_address, + native_token_address: chain.native_token().address, + }) + } + + fn encode_swap( + &self, + swap: &Swap, + _encoding_context: &EncodingContext, + ) -> Result, EncodingError> { + let direction = if *swap.token_in() == self.native_token_address && + *swap.token_out() == self.steth_address + { + LidoV3Direction::Submit + } else if *swap.token_in() == self.steth_address && *swap.token_out() == self.wsteth_address + { + LidoV3Direction::Wrap + } else if *swap.token_in() == self.wsteth_address && *swap.token_out() == self.steth_address + { + LidoV3Direction::Unwrap + } else { + return Err(EncodingError::InvalidInput("Combination not allowed".to_string())) + }; + + let args = (direction as u8).to_be_bytes(); + + Ok(args.abi_encode_packed()) + } + + fn executor_address(&self) -> &Bytes { + &self.executor_address + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +mod tests { + use alloy::hex::encode; + use tycho_common::models::protocol::ProtocolComponent; + + use super::*; + use crate::encoding::evm::utils::write_calldata_to_file; + + const STETH_ADDRESS: &str = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"; + const WSTETH_ADDRESS: &str = "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"; + + fn lido_v3_config() -> HashMap { + HashMap::from([ + ("steth_address".to_string(), STETH_ADDRESS.to_string()), + ("wsteth_address".to_string(), WSTETH_ADDRESS.to_string()), + ]) + } + + fn encoding_context(token_in: &Bytes, token_out: &Bytes) -> EncodingContext { + EncodingContext { + router_address: Some(Bytes::default()), + group_token_in: token_in.clone(), + group_token_out: token_out.clone(), + } + } + + fn encoder() -> LidoV3SwapEncoder { + LidoV3SwapEncoder::new( + Bytes::from("0x543778987b293C7E8Cf0722BB2e935ba6f4068D4"), + Chain::Ethereum, + Some(lido_v3_config()), + ) + .unwrap() + } + + #[test] + fn test_encode_lido_v3_submit() { + let component = ProtocolComponent { + id: STETH_ADDRESS.to_string(), + protocol_system: "lido_v3".to_string(), + ..Default::default() + }; + let token_in = Bytes::from("0x0000000000000000000000000000000000000000"); + let token_out = Bytes::from(STETH_ADDRESS); + let swap = Swap::new(component, token_in.clone(), token_out.clone()); + + let encoded_swap = encoder() + .encode_swap(&swap, &encoding_context(&token_in, &token_out)) + .unwrap(); + let hex_swap = encode(&encoded_swap); + + assert_eq!(hex_swap, "00"); + write_calldata_to_file("test_encode_lido_v3_submit", hex_swap.as_str()); + } + + #[test] + fn test_encode_lido_v3_wrap() { + let component = ProtocolComponent { + id: WSTETH_ADDRESS.to_string(), + protocol_system: "lido_v3".to_string(), + ..Default::default() + }; + let token_in = Bytes::from(STETH_ADDRESS); + let token_out = Bytes::from(WSTETH_ADDRESS); + let swap = Swap::new(component, token_in.clone(), token_out.clone()); + + let encoded_swap = encoder() + .encode_swap(&swap, &encoding_context(&token_in, &token_out)) + .unwrap(); + let hex_swap = encode(&encoded_swap); + + assert_eq!(hex_swap, "01"); + write_calldata_to_file("test_encode_lido_v3_wrap", hex_swap.as_str()); + } + + #[test] + fn test_encode_lido_v3_unwrap() { + let component = ProtocolComponent { + id: WSTETH_ADDRESS.to_string(), + protocol_system: "lido_v3".to_string(), + ..Default::default() + }; + let token_in = Bytes::from(WSTETH_ADDRESS); + let token_out = Bytes::from(STETH_ADDRESS); + let swap = Swap::new(component, token_in.clone(), token_out.clone()); + + let encoded_swap = encoder() + .encode_swap(&swap, &encoding_context(&token_in, &token_out)) + .unwrap(); + let hex_swap = encode(&encoded_swap); + + assert_eq!(hex_swap, "02"); + write_calldata_to_file("test_encode_lido_v3_unwrap", hex_swap.as_str()); + } + + #[test] + fn test_encode_lido_v3_invalid_pair() { + let component = ProtocolComponent { + id: STETH_ADDRESS.to_string(), + protocol_system: "lido_v3".to_string(), + ..Default::default() + }; + let token_in = Bytes::from(WSTETH_ADDRESS); + let token_out = Bytes::from("0x0000000000000000000000000000000000000000"); + let swap = Swap::new(component, token_in.clone(), token_out.clone()); + + let encoded_swap = encoder().encode_swap(&swap, &encoding_context(&token_in, &token_out)); + + assert!(encoded_swap.is_err()); + } +} diff --git a/crates/tycho-execution/src/encoding/evm/swap_encoder/mod.rs b/crates/tycho-execution/src/encoding/evm/swap_encoder/mod.rs index 726712c0b5..84ea9d87ac 100644 --- a/crates/tycho-execution/src/encoding/evm/swap_encoder/mod.rs +++ b/crates/tycho-execution/src/encoding/evm/swap_encoder/mod.rs @@ -9,6 +9,7 @@ mod erc_4626; mod etherfi; mod fluid_v1; mod hashflow; +mod lido_v3; mod liquidity_party; mod liquorice; mod maverick_v2; diff --git a/crates/tycho-execution/src/encoding/evm/swap_encoder/swap_encoder_registry.rs b/crates/tycho-execution/src/encoding/evm/swap_encoder/swap_encoder_registry.rs index dc62d2a6fd..043b8e35c5 100644 --- a/crates/tycho-execution/src/encoding/evm/swap_encoder/swap_encoder_registry.rs +++ b/crates/tycho-execution/src/encoding/evm/swap_encoder/swap_encoder_registry.rs @@ -11,11 +11,12 @@ use crate::encoding::{ balancer_v3::BalancerV3SwapEncoder, bebop::BebopSwapEncoder, curve::CurveSwapEncoder, ekubo::EkuboSwapEncoder, ekubo_v3::EkuboV3SwapEncoder, erc_4626::ERC4626SwapEncoder, etherfi::EtherfiSwapEncoder, fluid_v1::FluidV1SwapEncoder, - hashflow::HashflowSwapEncoder, liquidity_party::LiquidityPartySwapEncoder, - liquorice::LiquoriceSwapEncoder, maverick_v2::MaverickV2SwapEncoder, - rocketpool::RocketpoolSwapEncoder, slipstreams::SlipstreamsSwapEncoder, - uniswap_v2::UniswapV2SwapEncoder, uniswap_v3::UniswapV3SwapEncoder, - uniswap_v4::UniswapV4SwapEncoder, weth::WethSwapEncoder, + hashflow::HashflowSwapEncoder, lido_v3::LidoV3SwapEncoder, + liquidity_party::LiquidityPartySwapEncoder, liquorice::LiquoriceSwapEncoder, + maverick_v2::MaverickV2SwapEncoder, rocketpool::RocketpoolSwapEncoder, + slipstreams::SlipstreamsSwapEncoder, uniswap_v2::UniswapV2SwapEncoder, + uniswap_v3::UniswapV3SwapEncoder, uniswap_v4::UniswapV4SwapEncoder, + weth::WethSwapEncoder, }, }, swap_encoder::SwapEncoder, @@ -165,6 +166,9 @@ impl SwapEncoderRegistry { "etherfi" => { Ok(Box::new(EtherfiSwapEncoder::new(executor_address, self.chain, config)?)) } + "lido_v3" => { + Ok(Box::new(LidoV3SwapEncoder::new(executor_address, self.chain, config)?)) + } _ => Err(EncodingError::FatalError(format!( "Unknown protocol system: {}", protocol_system diff --git a/crates/tycho-execution/tests/protocol_integration_tests.rs b/crates/tycho-execution/tests/protocol_integration_tests.rs index c8e78eab7a..125b59d0d9 100644 --- a/crates/tycho-execution/tests/protocol_integration_tests.rs +++ b/crates/tycho-execution/tests/protocol_integration_tests.rs @@ -1636,6 +1636,190 @@ fn test_sequential_encoding_strategy_erc4626() { write_calldata_to_file("test_sequential_encoding_strategy_erc4626", hex_calldata.as_str()); } +#[test] +fn test_single_encoding_strategy_lido_v3_submit() { + // ETH -> (lido_v3) -> stETH + let steth = Bytes::from("0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"); + let component = ProtocolComponent { + id: String::from("0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"), + protocol_system: String::from("lido_v3"), + ..Default::default() + }; + let swap = Swap::new(component, eth(), steth.clone()); + + let encoder = get_tycho_router_encoder(); + let solution = Solution::new( + Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + eth(), + steth.clone(), + BigUint::from_str("1_000000000000000000").unwrap(), + BigUint::from_str("900000000000000000").unwrap(), + vec![swap], + ); + + let encoded_solution = encoder + .encode_solutions(vec![solution.clone()]) + .unwrap()[0] + .clone(); + + let calldata = encode_tycho_router_call( + eth_chain().id(), + encoded_solution, + &solution, + ð(), + None, + 0, + Bytes::zero(20), + BigUint::ZERO, + ) + .unwrap() + .data; + let hex_calldata = encode(&calldata); + write_calldata_to_file("test_single_encoding_strategy_lido_v3_submit", hex_calldata.as_str()); +} + +#[test] +fn test_single_encoding_strategy_lido_v3_wrap() { + // stETH -> (lido_v3) -> wstETH + let steth = Bytes::from("0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"); + let wsteth = Bytes::from("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"); + let component = ProtocolComponent { + id: String::from("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"), + protocol_system: String::from("lido_v3"), + ..Default::default() + }; + let swap = Swap::new(component, steth.clone(), wsteth.clone()); + + let encoder = get_tycho_router_encoder(); + let solution = Solution::new( + Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + steth.clone(), + wsteth.clone(), + BigUint::from_str("1_000000000000000000").unwrap(), + BigUint::from_str("800000000000000000").unwrap(), + vec![swap], + ); + + let encoded_solution = encoder + .encode_solutions(vec![solution.clone()]) + .unwrap()[0] + .clone(); + + let calldata = encode_tycho_router_call( + eth_chain().id(), + encoded_solution, + &solution, + ð(), + None, + 0, + Bytes::zero(20), + BigUint::ZERO, + ) + .unwrap() + .data; + let hex_calldata = encode(&calldata); + write_calldata_to_file("test_single_encoding_strategy_lido_v3_wrap", hex_calldata.as_str()); +} + +#[test] +fn test_single_encoding_strategy_lido_v3_unwrap() { + // wstETH -> (lido_v3) -> stETH + let steth = Bytes::from("0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"); + let wsteth = Bytes::from("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"); + let component = ProtocolComponent { + id: String::from("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"), + protocol_system: String::from("lido_v3"), + ..Default::default() + }; + let swap = Swap::new(component, wsteth.clone(), steth.clone()); + + let encoder = get_tycho_router_encoder(); + let solution = Solution::new( + Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + wsteth.clone(), + steth.clone(), + BigUint::from_str("1_000000000000000000").unwrap(), + BigUint::from_str("1000000000000000000").unwrap(), + vec![swap], + ); + + let encoded_solution = encoder + .encode_solutions(vec![solution.clone()]) + .unwrap()[0] + .clone(); + + let calldata = encode_tycho_router_call( + eth_chain().id(), + encoded_solution, + &solution, + ð(), + None, + 0, + Bytes::zero(20), + BigUint::ZERO, + ) + .unwrap() + .data; + let hex_calldata = encode(&calldata); + write_calldata_to_file("test_single_encoding_strategy_lido_v3_unwrap", hex_calldata.as_str()); +} + +#[test] +fn test_sequential_encoding_strategy_lido_v3_submit_then_wrap() { + // ETH -> (lido_v3) -> stETH -> (lido_v3) -> wstETH + let steth = Bytes::from("0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"); + let wsteth = Bytes::from("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"); + let submit_pool = ProtocolComponent { + id: String::from("0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"), + protocol_system: String::from("lido_v3"), + ..Default::default() + }; + let wrap_pool = ProtocolComponent { + id: String::from("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"), + protocol_system: String::from("lido_v3"), + ..Default::default() + }; + let swap1 = Swap::new(submit_pool, eth(), steth.clone()); + let swap2 = Swap::new(wrap_pool, steth.clone(), wsteth.clone()); + + let encoder = get_tycho_router_encoder(); + let solution = Solution::new( + Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + eth(), + wsteth.clone(), + BigUint::from_str("1_000000000000000000").unwrap(), + BigUint::from_str("800000000000000000").unwrap(), + vec![swap1, swap2], + ); + + let encoded_solution = encoder + .encode_solutions(vec![solution.clone()]) + .unwrap()[0] + .clone(); + + let calldata = encode_tycho_router_call( + eth_chain().id(), + encoded_solution, + &solution, + ð(), + None, + 0, + Bytes::zero(20), + BigUint::ZERO, + ) + .unwrap() + .data; + let hex_calldata = encode(&calldata); + write_calldata_to_file( + "test_sequential_encoding_strategy_lido_v3_submit_then_wrap", + hex_calldata.as_str(), + ); +} + #[test] #[ignore] // Performs real Angstrom API call fn test_single_swap_with_univ4_angstrom() { From 8cb864041f2069e1d07949e0c7d2a579912acfa2 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 24 Apr 2026 15:20:32 +0800 Subject: [PATCH 6/6] feat: add lido_v3 to protocol testing --- .../ethereum-lido-v3/integration_test.tycho.yaml | 4 ++-- .../substreams/ethereum-lido-v3/src/constants.rs | 4 ++-- .../substreams/ethereum-lido-v3/substreams.yaml | 6 +++--- protocols/testing/src/state_registry.rs | 13 ++++++++++--- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml b/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml index 18735e62f0..af191bb3d9 100644 --- a/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml +++ b/protocols/substreams/ethereum-lido-v3/integration_test.tycho.yaml @@ -17,7 +17,7 @@ tests: static_attributes: token_to_track_total_pooled_eth: "0x0000000000000000000000000000000000000000" creation_tx: "0xd377156654a0d6f646c9eea54e893352f98c26e8ff14911c4df8127578ff3b97" - skip_simulation: true # tycho-simulation does not have a Lido V3 native decoder in this repo yet. + skip_simulation: false skip_execution: true # tycho-execution does not have a Lido V3 adapter in this repo yet. - id: "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" tokens: @@ -26,5 +26,5 @@ tests: static_attributes: token_to_track_total_pooled_eth: "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" creation_tx: "0xd377156654a0d6f646c9eea54e893352f98c26e8ff14911c4df8127578ff3b97" - skip_simulation: true # tycho-simulation does not have a Lido V3 native decoder in this repo yet. + skip_simulation: false skip_execution: true # tycho-execution does not have a Lido V3 adapter in this repo yet. diff --git a/protocols/substreams/ethereum-lido-v3/src/constants.rs b/protocols/substreams/ethereum-lido-v3/src/constants.rs index 2bb8a98657..6d469fd779 100644 --- a/protocols/substreams/ethereum-lido-v3/src/constants.rs +++ b/protocols/substreams/ethereum-lido-v3/src/constants.rs @@ -1,7 +1,7 @@ use substreams::hex; -pub const STETH_COMPONENT_ID: &str = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"; -pub const WSTETH_COMPONENT_ID: &str = "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"; +pub const STETH_COMPONENT_ID: &str = "0xae7ab96520de3a18e5e111b5eaab095312d7fe84"; +pub const WSTETH_COMPONENT_ID: &str = "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0"; pub const STETH_ADDRESS: [u8; 20] = hex!("ae7ab96520de3a18e5e111b5eaab095312d7fe84"); pub const WSTETH_ADDRESS: [u8; 20] = hex!("7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0"); diff --git a/protocols/substreams/ethereum-lido-v3/substreams.yaml b/protocols/substreams/ethereum-lido-v3/substreams.yaml index dff723b33d..a3660663ec 100644 --- a/protocols/substreams/ethereum-lido-v3/substreams.yaml +++ b/protocols/substreams/ethereum-lido-v3/substreams.yaml @@ -5,10 +5,10 @@ package: protobuf: files: - - tycho/protocols/adapter-integration/evm/v1/common.proto - - tycho/protocols/adapter-integration/evm/v1/utils.proto + - tycho/evm/v1/common.proto + - tycho/evm/v1/utils.proto importPaths: - - ../../proto + - ../../../proto binaries: default: diff --git a/protocols/testing/src/state_registry.rs b/protocols/testing/src/state_registry.rs index 7fb8fcc097..17c407e7a8 100644 --- a/protocols/testing/src/state_registry.rs +++ b/protocols/testing/src/state_registry.rs @@ -2,9 +2,10 @@ use tycho_simulation::{ evm::{ engine_db::tycho_db::PreCachedDB, protocol::{ - ekubo::state::EkuboState, fluid::FluidV1, pancakeswap_v2::state::PancakeswapV2State, - uniswap_v2::state::UniswapV2State, uniswap_v3::state::UniswapV3State, - uniswap_v4::state::UniswapV4State, vm::state::EVMPoolState, + ekubo::state::EkuboState, fluid::FluidV1, lido_v3::state::LidoV3State, + pancakeswap_v2::state::PancakeswapV2State, uniswap_v2::state::UniswapV2State, + uniswap_v3::state::UniswapV3State, uniswap_v4::state::UniswapV4State, + vm::state::EVMPoolState, }, stream::ProtocolStreamBuilder, }, @@ -60,6 +61,12 @@ pub fn register_protocol( None, decoder_context, ), + "lido_v3" => stream_builder.exchange_with_decoder_context::( + protocol_system, + tvl_filter, + None, + decoder_context, + ), // Default to EVMPoolState for all other protocols _ => stream_builder.exchange_with_decoder_context::>( protocol_system,