use std::collections::{hash_map::Entry, HashMap}; use anyhow::{anyhow, bail}; use ethabi::{decode, ParamType}; use hex_literal::hex; use substreams_ethereum::pb::eth::{self}; use pb::tycho::evm::v1::{self as tycho}; mod pb; const AMBIENT_CONTRACT: [u8; 20] = hex!("aaaaaaaaa24eeeb8d57d431224f73832bc34f688"); const INIT_POOL_CODE: u8 = 71; const USER_CMD_FN_SIG: [u8; 4] = [0xA1, 0x51, 0x12, 0xF9]; struct SlotValue { new_value: Vec, start_value: Vec, } impl SlotValue { fn has_changed(&self) -> bool { self.start_value != self.new_value } } // Uses a map for slots, protobuf does not allow bytes in hashmap keys struct InterimContractChange { address: Vec, balance: Vec, code: Vec, slots: HashMap, SlotValue>, change: tycho::ChangeType, } impl From for tycho::ContractChange { fn from(value: InterimContractChange) -> Self { tycho::ContractChange { address: value.address, balance: value.balance, code: value.code, slots: value .slots .into_iter() .filter(|(_, value)| value.has_changed()) .map(|(slot, value)| tycho::ContractSlot { slot, value: value.new_value }) .collect(), change: value.change.into(), } } } /// Extracts all contract changes relevant to vm simulations /// /// This is the main logic of the substreams integration. It takes a raw ethereum block on input and extracts the BlockContractChanges stream. It includes tracking: /// - new pool initializations /// - all storage slot changes for the Ambient contract /// - all ERC20 balance changes for the Ambient pools /// - all code changes and balance updates of the Ambient contract /// /// Generally we detect all changes in transactions sequentially and detect if it is a CREATE or UPDATE change based on already present data. #[substreams::handlers::map] fn map_changes( block: eth::v2::Block, ) -> Result { let mut block_changes = tycho::BlockContractChanges::default(); let mut tx_change = tycho::TransactionContractChanges::default(); let mut changed_contracts: HashMap, InterimContractChange> = HashMap::new(); // Collect all accounts created in this block let created_accounts: HashMap<_, _> = block .transactions() .flat_map(|tx| { tx.calls.iter().flat_map(|call| { call.account_creations .iter() .map(|ac| (&ac.account, ac.ordinal)) }) }) .collect(); for block_tx in block.transactions() { // Extract storage changes for all contracts relevant to this protocol system. // Ambient is a protocol system consisting of many ProtocolComponents (one for each pool), but they all share the same AMBIENT_CONTRACT contract. let mut storage_changes = block_tx .calls .iter() .filter(|call| !call.state_reverted) .flat_map(|call| { call.storage_changes .iter() .filter(|c| c.address == AMBIENT_CONTRACT) }) .collect::>(); storage_changes.sort_unstable_by_key(|change| change.ordinal); // Detect all call to the Ambient contracts, even inner calls let ambient_calls = block_tx .calls .iter() .filter(|call| !call.state_reverted) .filter(|call| call.address == AMBIENT_CONTRACT) .collect::>(); // Detect all pool initializations // Official documentation: https://docs.ambient.finance/developers/dex-contract-interface/pool-initialization for call in ambient_calls { if call.input.len() < 4 { continue; } if call.input[0..4] == USER_CMD_FN_SIG { let user_cmd_external_abi_types = &[ // index of the proxy sidecar the command is being called on ParamType::Uint(16), // call data for internal UserCmd method ParamType::Bytes, ]; let user_cmd_internal_abi_types = &[ ParamType::Uint(8), // command ParamType::Address, // base ParamType::Address, // quote ParamType::Uint(256), // pool index ParamType::Uint(128), // price ]; // Decode external call to UserCmd if let Ok(external_params) = decode(user_cmd_external_abi_types, &call.input[4..]) { let cmd_bytes = external_params[1] .to_owned() .into_bytes() .ok_or_else(|| { anyhow!("Failed to convert to bytes: {:?}", &external_params[1]) })?; // Call data is structured differently depending on the cmd code, so only // decode if this is an init pool code. if cmd_bytes[31] == INIT_POOL_CODE { // Decode internal call to UserCmd if let Ok(internal_params) = decode(user_cmd_internal_abi_types, &cmd_bytes) { let base = internal_params[1] .to_owned() .into_address() .ok_or_else(|| { anyhow!( "Failed to convert to address: {:?}", &internal_params[1] ) })? .to_fixed_bytes() .to_vec(); let quote = internal_params[2] .to_owned() .into_address() .ok_or_else(|| { anyhow!( "Failed to convert to address: {:?}", &internal_params[2] ) })? .to_fixed_bytes() .to_vec(); let pool_index = internal_params[3] .to_owned() .into_uint() .ok_or_else(|| anyhow!("Failed to convert to u32".to_string()))? .as_u32(); let static_attribute = tycho::Attribute { name: String::from("pool_index"), value: pool_index.to_be_bytes().to_vec(), change: tycho::ChangeType::Creation.into(), }; let mut tokens: Vec> = vec![base.clone(), quote.clone()]; tokens.sort(); let new_component = tycho::ProtocolComponent { id: format!( "{}{}{}", hex::encode(base.clone()), hex::encode(quote.clone()), pool_index ), tokens, contracts: vec![AMBIENT_CONTRACT.to_vec()], static_att: vec![static_attribute], change: tycho::ChangeType::Creation.into(), }; tx_change .component_changes .push(new_component); } else { bail!("Failed to decode ABI internal call.".to_string()); } } } else { bail!("Failed to decode ABI external call.".to_string()); } } } // Extract all contract changes. // We cache the data in a general interim contract > slot > value data structure. // Note: some contracts change slot values and change them back to their // original value before the transactions ends we remember the initial // value before the first change and in the end filter found deltas // that ended up not actually changing anything. for storage_change in storage_changes.iter() { match changed_contracts.entry(storage_change.address.clone()) { // We have already an entry recording a change about this contract // only append the change about this storage slot Entry::Occupied(mut e) => { let contract_change = e.get_mut(); match contract_change .slots .entry(storage_change.key.clone()) { // The storage slot was already changed before, simply // update new_value Entry::Occupied(mut v) => { let slot_value = v.get_mut(); slot_value .new_value .copy_from_slice(&storage_change.new_value); } // The storage slots is being initialised for the first time Entry::Vacant(v) => { v.insert(SlotValue { new_value: storage_change.new_value.clone(), start_value: storage_change.old_value.clone(), }); } } } // Intialise a new contract change after observing a storage change Entry::Vacant(e) => { let mut slots = HashMap::new(); slots.insert( storage_change.key.clone(), SlotValue { new_value: storage_change.new_value.clone(), start_value: storage_change.old_value.clone(), }, ); e.insert(InterimContractChange { address: storage_change.address.clone(), balance: Vec::new(), code: Vec::new(), slots, change: if created_accounts.contains_key(&storage_change.address) { tycho::ChangeType::Creation } else { tycho::ChangeType::Update }, }); } } } // Extract balance changes let mut balance_changes = block_tx .calls .iter() .filter(|call| !call.state_reverted) .flat_map(|call| { call.balance_changes .iter() .filter(|c| c.address == AMBIENT_CONTRACT) }) .collect::>(); balance_changes.sort_unstable_by_key(|change| change.ordinal); for balance_change in balance_changes.iter() { match changed_contracts.entry(balance_change.address.clone()) { Entry::Occupied(mut e) => { let contract_change = e.get_mut(); if let Some(new_balance) = &balance_change.new_value { contract_change.balance.clear(); contract_change .balance .extend_from_slice(&new_balance.bytes); } } Entry::Vacant(e) => { if let Some(new_balance) = &balance_change.new_value { e.insert(InterimContractChange { address: balance_change.address.clone(), balance: new_balance.bytes.clone(), code: Vec::new(), slots: HashMap::new(), change: if created_accounts.contains_key(&balance_change.address) { tycho::ChangeType::Creation } else { tycho::ChangeType::Update }, }); } } } } // Extract code changes let mut code_changes = block_tx .calls .iter() .filter(|call| !call.state_reverted) .flat_map(|call| { call.code_changes .iter() .filter(|c| c.address == AMBIENT_CONTRACT) }) .collect::>(); code_changes.sort_unstable_by_key(|change| change.ordinal); for code_change in code_changes.iter() { match changed_contracts.entry(code_change.address.clone()) { Entry::Occupied(mut e) => { let contract_change = e.get_mut(); contract_change.code.clear(); contract_change .code .extend_from_slice(&code_change.new_code); } Entry::Vacant(e) => { e.insert(InterimContractChange { address: code_change.address.clone(), balance: Vec::new(), code: code_change.new_code.clone(), slots: HashMap::new(), change: if created_accounts.contains_key(&code_change.address) { tycho::ChangeType::Creation } else { tycho::ChangeType::Update }, }); } } } // If there were any changes, add transaction and push the changes if !storage_changes.is_empty() || !balance_changes.is_empty() || !code_changes.is_empty() { tx_change.tx = Some(tycho::Transaction { hash: block_tx.hash.clone(), from: block_tx.from.clone(), to: block_tx.to.clone(), index: block_tx.index as u64, }); // reuse changed_contracts hash map by draining it, next iteration // will start empty. This avoids a costly reallocation for (_, change) in changed_contracts.drain() { tx_change .contract_changes .push(change.into()) } block_changes .changes .push(tx_change.clone()); // clear out the interim contract changes after we pushed those. tx_change.tx = None; tx_change.contract_changes.clear(); } } block_changes.block = Some(tycho::Block { number: block.number, hash: block.hash.clone(), parent_hash: block .header .as_ref() .expect("Block header not present") .parent_hash .clone(), ts: block.timestamp_seconds(), }); Ok(block_changes) }