feat: Simulate txs against RPC (#261)

* feat: (WIP) Simulate txs against RPC

* fix: include test_executor_addresses.json

We should probably fetch this from execution instead. Will do later

* feat: (WIP) Attempt to use TokenProxyOverwriteFactory

* feat: (WIP) Attempt to use TokenProxyOverwriteFactory

* feat: Add simulating execution using debug_traceCall

Decoded amount out from execution and compared with the one from simulation
Allow for printing the execution traces
Moved all execution methods into execution.rs
Created traces.rs (heavily clauded) that prints the traces nicely and gets the function selectors frm 4byte (I decided not to use EtherscanIdentifier from foundry because adding foundry as a dependency is really heavy and I expect problems because of it)

Temporary: hardcoded storage slots for test_weighted_pool_v4 to pass

#time 1h 55m

* feat: Use BalanceSlotDetector and AllowanceSlotDetector from tycho_ethereum

#time 1h 14m

* fix: Improve traces.rs

#time 2m

* feat: Add skip_execution

Also get tycho router bytecode at compile time

#time 22m

* test: Skip tests that are before the Shanghai upgrade

Remove unnecessary TODOs

#time 35m

* feat: Point tycho dependencies to specific commits

#time 9m

* fix: Set historical_trade to true in TychoRouterEncoderBuilder

#time 41m


#time 0m

* feat: Improve traces.rs (use foundry instead of 4bytes)

Improved the format of the printed trace as well

#time 1h 7m

* fix: After merge fixes

#time 3m

* feat: Use new executors_addresses in tycho-execution and read it at startup

#time 1h 25m

* feat: Create a new flag to enable execution_traces

#time 9m

* fix: Small code, log and trace improvements

Add trying to decode method's calldatas in tracing

#time 1m

* fix: Simplify simulate_transactions_with_tracing by introducing a new method: bytes_to_fixed_32

#time 13m

* fix: After merge fixes

#time 1m

* fix: Include executor's bytecode at compile time

#time 11m

---------

Co-authored-by: TAMARA LIPOWSKI <data.lipowski@extaccount.com>
Co-authored-by: Diana Carvalho <diana@propellerheads.xyz>
This commit is contained in:
Tamara
2025-09-23 10:57:43 -04:00
committed by GitHub
parent dbabda84f2
commit eaca47479b
14 changed files with 4425 additions and 1028 deletions

View File

@@ -1,14 +1,15 @@
use std::{collections::HashMap, env, path::PathBuf, str::FromStr, sync::LazyLock};
use alloy::primitives::U256;
use alloy::primitives::{Address, U256};
use figment::{
providers::{Format, Yaml},
Figment,
};
use itertools::Itertools;
use miette::{miette, IntoDiagnostic, WrapErr};
use num_bigint::BigUint;
use num_traits::Zero;
use num_bigint::{BigInt, BigUint};
use num_rational::BigRational;
use num_traits::{Signed, ToPrimitive, Zero};
use postgres::{Client, Error, NoTls};
use tokio::runtime::Runtime;
use tracing::{debug, error, info, warn};
@@ -39,6 +40,7 @@ use crate::{
adapter_builder::AdapterContractBuilder,
config::{IntegrationTest, IntegrationTestsConfig, ProtocolComponentWithTestConfig},
encoding::encode_swap,
execution,
rpc::RPCProvider,
tycho_rpc::TychoClient,
tycho_runner::TychoRunner,
@@ -54,7 +56,8 @@ static CLONE_TO_BASE_PROTOCOL: LazyLock<HashMap<&str, &str>> = LazyLock::new(||
pub struct TestRunner {
db_url: String,
vm_traces: bool,
vm_simulation_traces: bool,
execution_traces: bool,
substreams_path: PathBuf,
adapter_contract_builder: AdapterContractBuilder,
match_test: Option<String>,
@@ -68,6 +71,7 @@ impl TestRunner {
match_test: Option<String>,
db_url: String,
vm_traces: bool,
execution_traces: bool,
) -> Self {
let base_protocol = CLONE_TO_BASE_PROTOCOL
.get(protocol.as_str())
@@ -96,7 +100,8 @@ impl TestRunner {
Self {
db_url,
vm_traces,
vm_simulation_traces: vm_traces,
execution_traces,
substreams_path,
adapter_contract_builder,
match_test,
@@ -112,10 +117,7 @@ impl TestRunner {
// Skip if test files don't exist
if !self.config_file_path.exists() {
warn!(
"Config file not found at {}.",
self.config_file_path.display()
);
warn!("Config file not found at {}.", self.config_file_path.display());
return Ok(());
}
@@ -237,7 +239,8 @@ impl TestRunner {
stop_block,
config,
&self.adapter_contract_builder,
self.vm_traces,
self.vm_simulation_traces,
self.execution_traces,
)
},
&test.expected_components,
@@ -268,7 +271,8 @@ fn validate_state(
stop_block: u64,
config: &IntegrationTestsConfig,
adapter_contract_builder: &AdapterContractBuilder,
vm_traces: bool,
vm_simulation_traces: bool,
execution_traces: bool,
) -> miette::Result<()> {
let rt = Runtime::new().unwrap();
@@ -314,10 +318,8 @@ fn validate_state(
.map(|s| (s.component_id.to_lowercase(), s))
.collect();
info!("Found {} protocol components", components_by_id.len());
info!("Found {} protocol states", protocol_states_by_id.len());
info!("Validating state...");
debug!("Found {} protocol components", components_by_id.len());
debug!("Found {} protocol states", protocol_states_by_id.len());
// Step 1: Validate that all expected components are present on Tycho after indexing
debug!("Validating {:?} expected components", expected_components.len());
@@ -350,10 +352,21 @@ fn validate_state(
info!("All expected components were successfully found on Tycho and match the expected state");
// Step 2: Validate Token Balances
let rpc_url = env::var("RPC_URL")
.into_diagnostic()
.wrap_err("Missing RPC_URL in environment")?;
let rpc_provider = RPCProvider::new(rpc_url, execution_traces);
match config.skip_balance_check {
true => info!("Skipping balance check"),
false => {
validate_token_balances(&components_by_id, &protocol_states_by_id, start_block, &rt)?;
validate_token_balances(
&components_by_id,
&protocol_states_by_id,
start_block,
&rt,
&rpc_provider,
)?;
info!("All token balances match the values found onchain")
}
}
@@ -368,7 +381,7 @@ fn validate_state(
info!("Components to simulate: {}", simulation_component_ids.len());
for id in &simulation_component_ids {
info!(" Simulating component: {}", id);
info!("Simulating component: {}", id);
}
if simulation_component_ids.is_empty() {
@@ -376,6 +389,13 @@ fn validate_state(
return Ok(());
}
// Filter out components that have skip_execution = true
let execution_component_ids: std::collections::HashSet<String> = expected_components
.iter()
.filter(|c| !c.skip_execution)
.map(|c| c.base.id.clone().to_lowercase())
.collect();
let adapter_contract_path;
let mut adapter_contract_path_str: Option<&str> = None;
@@ -386,7 +406,7 @@ fn validate_state(
adapter_contract_path = match adapter_contract_builder.find_contract(adapter_contract_name)
{
Ok(path) => {
info!("Found adapter contract at: {}", path.display());
debug!("Found adapter contract at: {}", path.display());
path
}
Err(_) => {
@@ -412,7 +432,7 @@ fn validate_state(
tycho_simulation::evm::engine_db::SHARED_TYCHO_DB.clear();
let mut decoder = TychoStreamDecoder::new();
let mut decoder_context = DecoderContext::new().vm_traces(vm_traces);
let mut decoder_context = DecoderContext::new().vm_traces(vm_simulation_traces);
if let Some(vm_adapter_path) = adapter_contract_path_str {
decoder_context = decoder_context.vm_adapter_path(vm_adapter_path);
@@ -461,7 +481,8 @@ fn validate_state(
state,
component: component.clone(),
component_tvl: None,
// Neither UniswapV4 with hooks not certain balancer pools are currently supported for SDK testing
// Neither UniswapV4 with hooks not certain balancer pools are currently supported for
// SDK testing
entrypoints: vec![],
};
states.insert(component_id.clone(), component_with_state);
@@ -477,10 +498,6 @@ fn validate_state(
let bytes = [0u8; 32];
// Get block header to extract the timestamp
let rpc_url = env::var("RPC_URL")
.into_diagnostic()
.wrap_err("Missing RPC_URL in environment")?;
let rpc_provider = RPCProvider::new(rpc_url);
let block_header = rt
.block_on(rpc_provider.get_block_header(stop_block))
.wrap_err("Failed to get block header")?;
@@ -505,7 +522,7 @@ fn validate_state(
.block_on(tycho_client.get_tokens(Chain::Ethereum, None, None))
.into_diagnostic()
.wrap_err("Failed to get tokens")?;
info!("Loaded {} tokens", all_tokens.len());
debug!("Loaded {} tokens", all_tokens.len());
rt.block_on(decoder.set_tokens(all_tokens));
@@ -514,7 +531,7 @@ fn validate_state(
let message: FeedMessage = FeedMessage { state_msgs, sync_states: Default::default() };
let block_msg = rt
.block_on(decoder.decode(message))
.block_on(decoder.decode(&message))
.into_diagnostic()
.wrap_err("Failed to decode message")?;
@@ -527,7 +544,6 @@ fn validate_state(
for (id, state) in block_msg.states.iter() {
if let Some(tokens) = pairs.get(id) {
let formatted_token_str = format!("{:}/{:}", &tokens[0].symbol, &tokens[1].symbol);
info!("Amount out for {}: calculating for tokens {:?}", id, formatted_token_str);
state
.spot_price(&tokens[0], &tokens[1])
.map(|price| info!("Spot price {:?}: {:?}", formatted_token_str, price))
@@ -584,7 +600,7 @@ fn validate_state(
))?;
info!(
"Amount out for trading {:.1}% of max: ({} {} -> {} {}) (gas: {})",
"Simulated amount out for trading {:.1}% of max: ({} {} -> {} {}) (gas: {})",
percentage * 100.0,
amount_in,
token_in.symbol,
@@ -593,22 +609,68 @@ fn validate_state(
amount_out_result.gas
);
let protocol_component = block_msg.new_pairs.get(id);
if let Some(pc) = protocol_component {
encode_swap(
pc.clone(),
token_in.address.clone(),
token_out.address.clone(),
amount_in,
amount_out_result.amount,
)?;
info!("Encoded swap successfully");
// Only execute for components that should have execution
if !execution_component_ids.contains(id) {
info!("Skipping execution for component {id}");
continue;
}
let protocol_component = block_msg.new_pairs.get(id).unwrap();
let (calldata, solution) = encode_swap(
protocol_component.clone(),
token_in.address.clone(),
token_out.address.clone(),
amount_in,
amount_out_result.amount.clone(),
)?;
info!("Simulating swap at historical block {}", block_header.header.number);
// Simulate the trade using debug_traceCall with overwrites
let execution_amount_out =
rt.block_on(execution::simulate_trade_with_eth_call(
&rpc_provider,
&calldata,
&solution,
stop_block,
&block_header,
));
match execution_amount_out {
Ok(amount_out) => {
info!(
"Simulating execution passed with {} {} -> {} {}",
solution.given_amount,
token_in.symbol,
amount_out,
token_out.symbol
);
// Compare execution amount out with simulation amount out
let diff = BigInt::from(amount_out_result.amount) -
BigInt::from(amount_out.clone());
let slippage: BigRational =
BigRational::new(diff.abs(), BigInt::from(amount_out));
if slippage.to_f64() > Some(0.05) {
return Err(miette!(
"Execution amount and simulation amount differ more than 5%!"
));
}
}
Err(e) => {
return Err(miette!(
"Simulating execution failed for {} -> {}: {}",
token_in.symbol,
token_out.symbol,
e
));
}
}
}
}
}
}
info!("✅ Simulation validation passed");
Ok(())
}
@@ -619,12 +681,8 @@ fn validate_token_balances(
protocol_states_by_id: &HashMap<String, ResponseProtocolState>,
start_block: u64,
rt: &Runtime,
rpc_provider: &RPCProvider,
) -> miette::Result<()> {
let rpc_url = env::var("RPC_URL")
.into_diagnostic()
.wrap_err("Missing RPC_URL in environment")?;
let rpc_provider = RPCProvider::new(rpc_url.to_string());
for (id, component) in components_by_id.iter() {
let component_state = protocol_states_by_id.get(id);
@@ -640,8 +698,8 @@ fn validate_token_balances(
}
info!("Validating token balance for component {} and token {}", component.id, token);
let token_address = alloy::primitives::Address::from_slice(&token[..20]);
let component_address = alloy::primitives::Address::from_str(component.id.as_str())
let token_address = Address::from_slice(&token[..20]);
let component_address = Address::from_str(component.id.as_str())
.expect("Failed to parse component address");
let node_balance = rt.block_on(rpc_provider.get_token_balance(
token_address,
@@ -666,7 +724,7 @@ fn validate_token_balances(
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::{collections::HashMap, str::FromStr};
use dotenv::dotenv;
use glob::glob;
@@ -725,6 +783,9 @@ mod tests {
#[test]
fn test_token_balance_validation() {
dotenv().ok();
let eth_rpc_url = env::var("RPC_URL").expect("Missing RPC_URL in environment");
let rpc_provider = RPCProvider::new(eth_rpc_url, false);
// Setup mock data
let block_number = 21998530;
let token_bytes = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap();
@@ -756,13 +817,21 @@ mod tests {
let rt = Runtime::new().unwrap();
dotenv().ok();
let result =
validate_token_balances(&components_by_id, &protocol_states_by_id, block_number, &rt);
let result = validate_token_balances(
&components_by_id,
&protocol_states_by_id,
block_number,
&rt,
&rpc_provider,
);
assert!(result.is_ok(), "Should pass when balance check is performed and balances match");
}
#[test]
fn test_token_balance_validation_fails_on_mismatch() {
dotenv().ok();
let eth_rpc_url = env::var("RPC_URL").expect("Missing RPC_URL in environment");
let rpc_provider = RPCProvider::new(eth_rpc_url, false);
// Setup mock data
let block_number = 21998530;
let token_bytes = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap();
@@ -791,8 +860,13 @@ mod tests {
let rt = Runtime::new().unwrap();
dotenv().ok();
let result =
validate_token_balances(&components_by_id, &protocol_states_by_id, block_number, &rt);
let result = validate_token_balances(
&components_by_id,
&protocol_states_by_id,
block_number,
&rt,
&rpc_provider,
);
assert!(
result.is_err(),
"Should fail when balance check is performed and balances do not match"