981 lines
37 KiB
Rust
981 lines
37 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
path::PathBuf,
|
|
str::FromStr,
|
|
sync::LazyLock,
|
|
};
|
|
|
|
use alloy::{
|
|
primitives::{Address, U256},
|
|
rpc::types::Block,
|
|
};
|
|
use figment::{
|
|
providers::{Format, Yaml},
|
|
Figment,
|
|
};
|
|
use itertools::Itertools;
|
|
use miette::{miette, IntoDiagnostic, WrapErr};
|
|
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};
|
|
use tycho_execution::encoding::evm::utils::bytes_to_address;
|
|
use tycho_simulation::{
|
|
evm::{decoder::TychoStreamDecoder, protocol::u256_num::bytes_to_u256},
|
|
protocol::models::{DecoderContext, Update},
|
|
tycho_client::feed::{
|
|
synchronizer::{ComponentWithState, Snapshot, StateSyncMessage},
|
|
BlockHeader, FeedMessage,
|
|
},
|
|
tycho_common::{
|
|
dto::{
|
|
Chain, EntryPointWithTracingParams, ProtocolComponent, ResponseAccount,
|
|
ResponseProtocolState, TracingResult,
|
|
},
|
|
models::token::Token,
|
|
Bytes,
|
|
},
|
|
};
|
|
|
|
use crate::{
|
|
adapter_builder::AdapterContractBuilder,
|
|
config::{IntegrationTest, IntegrationTestsConfig, ProtocolComponentWithTestConfig},
|
|
encoding::encode_swap,
|
|
execution,
|
|
rpc::RPCProvider,
|
|
state_registry::register_decoder_for_protocol,
|
|
tycho_rpc::TychoClient,
|
|
tycho_runner::TychoRunner,
|
|
utils::build_spkg,
|
|
};
|
|
|
|
static CLONE_TO_BASE_PROTOCOL: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
|
|
HashMap::from([
|
|
("ethereum-sushiswap-v2", "ethereum-uniswap-v2"),
|
|
("ethereum-pancakeswap-v2", "ethereum-uniswap-v2"),
|
|
])
|
|
});
|
|
|
|
pub struct TestRunner {
|
|
db_url: String,
|
|
vm_simulation_traces: bool,
|
|
substreams_path: PathBuf,
|
|
adapter_contract_builder: AdapterContractBuilder,
|
|
match_test: Option<String>,
|
|
config_file_path: PathBuf,
|
|
runtime: Runtime,
|
|
rpc_provider: RPCProvider,
|
|
}
|
|
|
|
impl TestRunner {
|
|
pub fn new(
|
|
root_path: PathBuf,
|
|
protocol: String,
|
|
match_test: Option<String>,
|
|
db_url: String,
|
|
vm_traces: bool,
|
|
execution_traces: bool,
|
|
rpc_url: String,
|
|
) -> miette::Result<Self> {
|
|
let base_protocol = CLONE_TO_BASE_PROTOCOL
|
|
.get(protocol.as_str())
|
|
.unwrap_or(&protocol.as_str())
|
|
.to_string();
|
|
let substreams_path = root_path
|
|
.join("substreams")
|
|
.join(&base_protocol);
|
|
let evm_path = root_path.join("evm");
|
|
let adapter_contract_builder =
|
|
AdapterContractBuilder::new(evm_path.to_string_lossy().to_string());
|
|
|
|
// Calculate config file path based on protocol. If the protocol is a clone of another
|
|
// protocol, we assume this protocol name will be appended to the integration test filename.
|
|
let config_file_name = if protocol != base_protocol {
|
|
format!(
|
|
"integration_test_{}.tycho.yaml",
|
|
protocol
|
|
.replace("ethereum-", "")
|
|
.replace('-', "_")
|
|
)
|
|
} else {
|
|
"integration_test.tycho.yaml".to_string()
|
|
};
|
|
let config_file_path = substreams_path.join(&config_file_name);
|
|
|
|
let rpc_provider = RPCProvider::new(rpc_url, execution_traces);
|
|
let runtime = Runtime::new().into_diagnostic()?;
|
|
|
|
Ok(Self {
|
|
db_url,
|
|
vm_simulation_traces: vm_traces,
|
|
runtime,
|
|
rpc_provider,
|
|
substreams_path,
|
|
adapter_contract_builder,
|
|
match_test,
|
|
config_file_path,
|
|
})
|
|
}
|
|
|
|
pub fn run_tests(&self) -> miette::Result<()> {
|
|
let terminal_width = termsize::get()
|
|
.map(|size| size.cols as usize - 35) // Remove length of log prefix (35)
|
|
.unwrap_or(80);
|
|
info!("{}\n", "-".repeat(terminal_width));
|
|
|
|
// Skip if test files don't exist
|
|
if !self.config_file_path.exists() {
|
|
warn!("Config file not found at {}.", self.config_file_path.display());
|
|
return Ok(());
|
|
}
|
|
|
|
let config = match Self::parse_config(&self.config_file_path) {
|
|
Ok(cfg) => cfg,
|
|
Err(e) => {
|
|
warn!("Failed to parse config: {:?}", e);
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let substreams_yaml_path = self
|
|
.substreams_path
|
|
.join(&config.substreams_yaml_path);
|
|
if !substreams_yaml_path.exists() {
|
|
warn!("substreams.yaml file not found at {}", substreams_yaml_path.display());
|
|
return Ok(());
|
|
}
|
|
|
|
let tests = match &self.match_test {
|
|
Some(filter) => config
|
|
.tests
|
|
.iter()
|
|
.filter(|test| test.name.contains(filter))
|
|
.collect::<Vec<&IntegrationTest>>(),
|
|
None => config
|
|
.tests
|
|
.iter()
|
|
.collect::<Vec<&IntegrationTest>>(),
|
|
};
|
|
let tests_count = tests.len();
|
|
|
|
info!("Running {} tests ...\n", tests_count);
|
|
|
|
let mut failed_tests: Vec<String> = Vec::new();
|
|
let mut count = 1;
|
|
|
|
for test in &tests {
|
|
info!("TEST {}: {}", count, test.name);
|
|
|
|
match self.run_test(test, &config) {
|
|
Ok(_) => {
|
|
info!("✅ {} passed\n", test.name);
|
|
}
|
|
Err(e) => {
|
|
failed_tests.push(test.name.clone());
|
|
error!("❗️{} failed: {:?}\n", test.name, e);
|
|
}
|
|
}
|
|
|
|
info!("{}\n", "-".repeat(terminal_width));
|
|
count += 1;
|
|
}
|
|
|
|
info!("Tests finished!");
|
|
info!("Passed {}/{}\n", tests_count - failed_tests.len(), tests_count);
|
|
if !failed_tests.is_empty() {
|
|
Err(miette!("Failed tests: {}", failed_tests.join(", ")))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn parse_config(config_yaml_path: &PathBuf) -> miette::Result<IntegrationTestsConfig> {
|
|
info!("Parsing config YAML at {}", config_yaml_path.display());
|
|
let yaml = Yaml::file(config_yaml_path);
|
|
let figment = Figment::new().merge(yaml);
|
|
let config = figment
|
|
.extract::<IntegrationTestsConfig>()
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to load test configuration:")?;
|
|
Ok(config)
|
|
}
|
|
|
|
fn run_test(
|
|
&self,
|
|
test: &IntegrationTest,
|
|
config: &IntegrationTestsConfig,
|
|
) -> miette::Result<()> {
|
|
self.empty_database()
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to empty the database")?;
|
|
|
|
let substreams_yaml_path = self
|
|
.substreams_path
|
|
.join(&config.substreams_yaml_path);
|
|
|
|
let mut initialized_accounts = config
|
|
.initialized_accounts
|
|
.clone()
|
|
.unwrap_or_default();
|
|
initialized_accounts.extend(
|
|
test.initialized_accounts
|
|
.clone()
|
|
.unwrap_or_default(),
|
|
);
|
|
|
|
let spkg_path =
|
|
build_spkg(&substreams_yaml_path, test.start_block).wrap_err("Failed to build spkg")?;
|
|
|
|
let tycho_runner = TychoRunner::new(self.db_url.clone(), initialized_accounts);
|
|
|
|
tycho_runner
|
|
.run_tycho(
|
|
spkg_path.as_str(),
|
|
test.start_block,
|
|
test.stop_block,
|
|
&config.protocol_type_names,
|
|
&config.protocol_system,
|
|
config.module_name.clone(),
|
|
)
|
|
.wrap_err("Failed to run Tycho")?;
|
|
|
|
let rpc_server = tycho_runner.start_rpc_server()?;
|
|
|
|
let expected_ids = test
|
|
.expected_components
|
|
.iter()
|
|
.map(|c| c.base.id.to_lowercase())
|
|
.collect::<Vec<String>>();
|
|
|
|
let (update, component_tokens, response_protocol_states_by_id, block) = self
|
|
.fetch_from_tycho_rpc(
|
|
&config.protocol_system,
|
|
expected_ids,
|
|
&config.adapter_contract,
|
|
&config.adapter_build_signature,
|
|
&config.adapter_build_args,
|
|
self.vm_simulation_traces,
|
|
test.stop_block,
|
|
)?;
|
|
|
|
// Step 1: Validate that all expected components are present on Tycho after indexing
|
|
self.validate_state(&test.expected_components, update.clone())?;
|
|
|
|
// Step 2: Validate Token Balances
|
|
match config.skip_balance_check {
|
|
true => info!("Skipping balance check"),
|
|
false => {
|
|
self.validate_token_balances(
|
|
&component_tokens,
|
|
&response_protocol_states_by_id,
|
|
test.stop_block,
|
|
)?;
|
|
info!("All token balances match the values found onchain")
|
|
}
|
|
}
|
|
// Step 3: Run Tycho Simulation and Execution
|
|
self.simulate_and_execute(
|
|
update,
|
|
&component_tokens,
|
|
block,
|
|
Some(test.expected_components.clone()),
|
|
)?;
|
|
|
|
tycho_runner.stop_rpc_server(rpc_server)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn empty_database(&self) -> Result<(), Error> {
|
|
// Remove db name from URL. This is required because we cannot drop a database that we are
|
|
// currently connected to.
|
|
let base_url = match self.db_url.rfind('/') {
|
|
Some(pos) => &self.db_url[..pos],
|
|
None => self.db_url.as_str(),
|
|
};
|
|
let mut client = Client::connect(base_url, NoTls)?;
|
|
|
|
client.execute("DROP DATABASE IF EXISTS \"tycho_indexer_0\" WITH (FORCE)", &[])?;
|
|
client.execute("CREATE DATABASE \"tycho_indexer_0\"", &[])?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetches protocol data from the Tycho RPC server and prepares it for validation and
|
|
/// simulation.
|
|
///
|
|
/// This method connects to the running Tycho RPC server to retrieve protocol components,
|
|
/// states, and contract storage. It then sets up the Tycho Decoder and creates an update
|
|
/// message that can be used for validation and simulation testing.
|
|
///
|
|
/// # Arguments
|
|
/// * `protocol_system` - The protocol system identifier (e.g., "uniswap_v2", "balancer_v2")
|
|
/// * `expected_component_ids` - List of component IDs to fetch from Tycho
|
|
/// * `adapter_contract` - Optional adapter contract name for VM-based protocols
|
|
/// * `adapter_build_signature` - Optional build signature for the adapter contract
|
|
/// * `adapter_build_args` - Optional build arguments for the adapter contract
|
|
/// * `vm_simulation_traces` - Whether to enable VM simulation traces
|
|
/// * `stop_block` - The block number to fetch data for
|
|
///
|
|
/// # Returns
|
|
/// A tuple containing:
|
|
/// - `Update` - Decoded protocol state update for simulation
|
|
/// - `HashMap<String, Vec<Token>>` - Token mappings for each component
|
|
/// - `HashMap<String, ResponseProtocolState>` - Protocol states by component ID
|
|
/// - `Block` - The block header for the specified block
|
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
|
fn fetch_from_tycho_rpc(
|
|
&self,
|
|
protocol_system: &str,
|
|
expected_component_ids: Vec<String>,
|
|
adapter_contract: &Option<String>,
|
|
adapter_build_signature: &Option<String>,
|
|
adapter_build_args: &Option<String>,
|
|
vm_simulation_traces: bool,
|
|
stop_block: u64,
|
|
) -> miette::Result<(
|
|
Update,
|
|
HashMap<String, Vec<Token>>,
|
|
HashMap<String, ResponseProtocolState>,
|
|
Block,
|
|
)> {
|
|
// Create Tycho client for the RPC server
|
|
let tycho_client = TychoClient::new("http://localhost:4242")
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to create Tycho client")?;
|
|
|
|
let chain = Chain::Ethereum;
|
|
|
|
// Fetch data from Tycho RPC. We use block_on to avoid using async functions on the testing
|
|
// module, in order to simplify debugging
|
|
let protocol_components = self
|
|
.runtime
|
|
.block_on(tycho_client.get_protocol_components(protocol_system, chain))
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to get protocol components")?;
|
|
|
|
let protocol_states = self
|
|
.runtime
|
|
.block_on(tycho_client.get_protocol_state(
|
|
protocol_system,
|
|
expected_component_ids.clone(),
|
|
chain,
|
|
))
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to get protocol state")?;
|
|
|
|
let vm_storages = self
|
|
.runtime
|
|
.block_on(tycho_client.get_contract_state(protocol_system, chain))
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to get contract state")?;
|
|
|
|
let traced_entry_points = self
|
|
.runtime
|
|
.block_on(tycho_client.get_traced_entry_points(
|
|
protocol_system,
|
|
expected_component_ids.clone(),
|
|
chain,
|
|
))
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to get trace points")?;
|
|
|
|
// Create a map of component IDs to components for easy lookup
|
|
let mut components_by_id: HashMap<String, ProtocolComponent> = protocol_components
|
|
.clone()
|
|
.into_iter()
|
|
.map(|c| (c.id.to_lowercase(), c))
|
|
.collect();
|
|
if !expected_component_ids.is_empty() {
|
|
components_by_id.retain(|id, _| expected_component_ids.contains(id))
|
|
};
|
|
|
|
let protocol_states_by_id: HashMap<String, ResponseProtocolState> = protocol_states
|
|
.into_iter()
|
|
.map(|s| (s.component_id.to_lowercase(), s))
|
|
.collect();
|
|
|
|
debug!("Found {} protocol components", components_by_id.len());
|
|
debug!("Found {} protocol states", protocol_states_by_id.len());
|
|
debug!("Found {} traced entry points", traced_entry_points.len());
|
|
|
|
let adapter_contract_path;
|
|
let mut adapter_contract_path_str: Option<&str> = None;
|
|
|
|
// Adapter contract will only be configured for VM protocols, not natively implemented
|
|
// protocols.
|
|
if let Some(adapter_contract_name) = &adapter_contract {
|
|
// Build/find the adapter contract
|
|
adapter_contract_path = match self
|
|
.adapter_contract_builder
|
|
.find_contract(adapter_contract_name)
|
|
{
|
|
Ok(path) => {
|
|
debug!("Found adapter contract at: {}", path.display());
|
|
path
|
|
}
|
|
Err(_) => {
|
|
info!("Adapter contract not found, building it...");
|
|
self.adapter_contract_builder
|
|
.build_target(
|
|
adapter_contract_name,
|
|
adapter_build_signature.as_deref(),
|
|
adapter_build_args.as_deref(),
|
|
)
|
|
.wrap_err("Failed to build adapter contract")?
|
|
}
|
|
};
|
|
|
|
debug!("Using adapter contract: {}", adapter_contract_path.display());
|
|
adapter_contract_path_str = Some(adapter_contract_path.to_str().unwrap());
|
|
}
|
|
|
|
// Clear the shared database state to ensure test isolation
|
|
// This prevents state from previous tests from affecting the current test
|
|
tycho_simulation::evm::engine_db::SHARED_TYCHO_DB.clear();
|
|
|
|
let mut decoder = TychoStreamDecoder::new();
|
|
decoder.skip_state_decode_failures(true);
|
|
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);
|
|
}
|
|
register_decoder_for_protocol(&mut decoder, protocol_system, decoder_context)?;
|
|
|
|
// Mock a stream message, with only a Snapshot and no deltas
|
|
let mut states: HashMap<String, ComponentWithState> = HashMap::new();
|
|
for (id, component) in &components_by_id {
|
|
let component_id = id;
|
|
|
|
let state = protocol_states_by_id
|
|
.get(component_id)
|
|
.wrap_err(format!("No state found for component: {id}"))?
|
|
.clone();
|
|
|
|
let traced_entry_points: Vec<(EntryPointWithTracingParams, TracingResult)> =
|
|
traced_entry_points
|
|
.get(component_id)
|
|
.map(|inner| {
|
|
inner
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let component_with_state = ComponentWithState {
|
|
state,
|
|
component: component.clone(),
|
|
component_tvl: None,
|
|
// Neither UniswapV4 with hooks not certain balancer pools are currently supported
|
|
// for SDK testing
|
|
entrypoints: traced_entry_points,
|
|
};
|
|
states.insert(component_id.clone(), component_with_state);
|
|
}
|
|
|
|
// Convert vm_storages to a HashMap
|
|
let vm_storage: HashMap<Bytes, ResponseAccount> = vm_storages
|
|
.into_iter()
|
|
.map(|x| (x.address.clone(), x))
|
|
.collect();
|
|
|
|
let snapshot = Snapshot { states, vm_storage };
|
|
|
|
// Get block header to extract the timestamp
|
|
let block_header = self
|
|
.runtime
|
|
.block_on(
|
|
self.rpc_provider
|
|
.get_block_header(stop_block),
|
|
)
|
|
.wrap_err("Failed to get block header")?;
|
|
|
|
let state_msgs: HashMap<String, StateSyncMessage<BlockHeader>> = HashMap::from([(
|
|
String::from(protocol_system),
|
|
StateSyncMessage {
|
|
header: BlockHeader {
|
|
hash: (*block_header.hash()).into(),
|
|
number: stop_block,
|
|
parent_hash: Bytes::default(),
|
|
revert: false,
|
|
timestamp: block_header.header.timestamp,
|
|
},
|
|
snapshots: snapshot,
|
|
deltas: None,
|
|
removed_components: HashMap::new(),
|
|
},
|
|
)]);
|
|
|
|
let all_tokens = self
|
|
.runtime
|
|
.block_on(tycho_client.get_tokens(Chain::Ethereum, None, None))
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to get tokens")?;
|
|
debug!("Loaded {} tokens", all_tokens.len());
|
|
|
|
self.runtime
|
|
.block_on(decoder.set_tokens(all_tokens));
|
|
|
|
let message: FeedMessage = FeedMessage { state_msgs, sync_states: Default::default() };
|
|
|
|
let block_msg = self
|
|
.runtime
|
|
.block_on(decoder.decode(&message))
|
|
.into_diagnostic()
|
|
.wrap_err("Failed to decode message")?;
|
|
|
|
let mut component_tokens: HashMap<String, Vec<Token>> = HashMap::new();
|
|
|
|
for (id, comp) in block_msg.new_pairs.iter() {
|
|
component_tokens
|
|
.entry(id.clone())
|
|
.or_insert_with(|| comp.tokens.clone());
|
|
}
|
|
|
|
Ok((block_msg, component_tokens, protocol_states_by_id, block_header))
|
|
}
|
|
|
|
/// Validates that the protocol components retrieved from Tycho match the expected
|
|
/// configuration.
|
|
///
|
|
/// This method compares each expected component from the test configuration against
|
|
/// the actual components found in the protocol state update. It ensures that all
|
|
/// expected components are present and their properties (tokens, addresses, fees, etc.)
|
|
/// match the expected values.
|
|
///
|
|
/// # Arguments
|
|
/// * `expected_components` - Vector of expected protocol components with their test
|
|
/// configuration
|
|
/// * `block_msg` - The decoded protocol state update containing the actual component data
|
|
///
|
|
/// # Returns
|
|
/// Returns `Ok(())` if all expected components are found and match their expected state.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if:
|
|
/// - Any expected component is missing from the Tycho state
|
|
/// - Any component's properties don't match the expected values (shows detailed diff)
|
|
fn validate_state(
|
|
&self,
|
|
expected_components: &Vec<ProtocolComponentWithTestConfig>,
|
|
block_msg: Update,
|
|
) -> miette::Result<()> {
|
|
debug!("Validating {:?} expected components", expected_components.len());
|
|
for expected_component in expected_components {
|
|
let component_id = expected_component
|
|
.base
|
|
.id
|
|
.to_lowercase();
|
|
|
|
let component = block_msg
|
|
.new_pairs
|
|
.get(&component_id)
|
|
.ok_or_else(|| miette!("Component {:?} was not found on Tycho", component_id))?;
|
|
|
|
let diff = expected_component
|
|
.base
|
|
.compare(component, true);
|
|
match diff {
|
|
Some(diff) => {
|
|
return Err(miette!(
|
|
"Component {} does not match the expected state:\n{}",
|
|
component_id,
|
|
diff
|
|
));
|
|
}
|
|
None => {
|
|
info!("Component {} matches the expected state", component_id);
|
|
}
|
|
}
|
|
}
|
|
info!(
|
|
"All expected components were successfully found on Tycho and match the expected state"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Performs comprehensive simulation and execution testing on protocol components.
|
|
///
|
|
/// This method tests each protocol component by:
|
|
/// 1. Computing spot prices for all token pairs
|
|
/// 2. Simulating swaps with different input amounts (0.1%, 1%, 10% of limits)
|
|
/// 3. Testing all possible swap directions between tokens
|
|
/// 4. Simulating actual execution using historical block state
|
|
/// 5. Comparing simulation results with execution results for accuracy
|
|
///
|
|
/// The simulation uses the Tycho SDK to calculate expected outputs, while execution
|
|
/// uses `debug_traceCall` with state overwrites to simulate actual on-chain behavior
|
|
/// at historical blocks.
|
|
///
|
|
/// # Arguments
|
|
/// * `update` - The decoded protocol state containing all component data
|
|
/// * `component_tokens` - Mapping of component IDs to their associated tokens
|
|
/// * `block` - The historical block to use for execution testing
|
|
/// * `expected_components` - Optional test configuration to determine which components to skip
|
|
///
|
|
/// # Returns
|
|
/// Returns `Ok(())` if all simulations and executions complete successfully within tolerance.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if:
|
|
/// - Spot price calculation fails for any component
|
|
/// - Simulation fails to calculate amount out
|
|
/// - Execution simulation fails or reverts
|
|
/// - Difference between simulation and execution exceeds 5% slippage tolerance
|
|
///
|
|
/// Components can be skipped using `skip_simulation` or `skip_execution` flags
|
|
/// in the test configuration.
|
|
fn simulate_and_execute(
|
|
&self,
|
|
update: Update,
|
|
component_tokens: &HashMap<String, Vec<Token>>,
|
|
block: Block,
|
|
expected_components: Option<Vec<ProtocolComponentWithTestConfig>>,
|
|
) -> miette::Result<()> {
|
|
let mut skip_simulation = HashSet::new();
|
|
let mut skip_execution = HashSet::new();
|
|
if let Some(components) = expected_components {
|
|
skip_simulation = components
|
|
.iter()
|
|
.filter(|c| c.skip_simulation)
|
|
.map(|c| c.base.id.to_lowercase())
|
|
.collect();
|
|
skip_execution = components
|
|
.iter()
|
|
.filter(|c| c.skip_execution)
|
|
.map(|c| c.base.id.to_lowercase())
|
|
.collect();
|
|
}
|
|
|
|
for (id, state) in update.states.iter() {
|
|
if skip_simulation.contains(id) {
|
|
info!("Skipping simulation for component {id}");
|
|
continue
|
|
}
|
|
if let Some(tokens) = component_tokens.get(id) {
|
|
let formatted_token_str = format!("{:}/{:}", &tokens[0].symbol, &tokens[1].symbol);
|
|
state
|
|
.spot_price(&tokens[0], &tokens[1])
|
|
.map(|price| info!("Spot price {:?}: {:?}", formatted_token_str, price))
|
|
.into_diagnostic()
|
|
.wrap_err(format!("Error calculating spot price for Pool {id:?}."))?;
|
|
|
|
// Test get_amount_out with different percentages of limits. The reserves or limits
|
|
// are relevant because we need to know how much to test with. We
|
|
// don't know if a pool is going to revert with 10 or 10 million
|
|
// USDC, for example, so by using the limits we can use "safe
|
|
// values" where the sim shouldn't break. We then retrieve the
|
|
// amount out for 0.1%, 1% and 10%.
|
|
let percentages = [0.001, 0.01, 0.1];
|
|
|
|
// Test all permutations of swap directions
|
|
let swap_directions: Vec<_> = tokens
|
|
.iter()
|
|
.permutations(2)
|
|
.map(|perm| (perm[0], perm[1]))
|
|
.collect();
|
|
|
|
for (token_in, token_out) in &swap_directions {
|
|
let (max_input, max_output) = state
|
|
.get_limits(token_in.address.clone(), token_out.address.clone())
|
|
.into_diagnostic()
|
|
.wrap_err(format!(
|
|
"Error getting limits for Pool {id:?} for in token: {}, and out token: {}",
|
|
token_in.address, token_out.address
|
|
))?;
|
|
|
|
info!(
|
|
"Retrieved limits. | Max input: {max_input} {} | Max output: {max_output} {}",
|
|
token_in.symbol, token_out.symbol
|
|
);
|
|
|
|
for percentage in &percentages {
|
|
// For precision, multiply by 1000 then divide by 1000
|
|
let percentage_biguint = BigUint::from((percentage * 1000.0) as u32);
|
|
let thousand = BigUint::from(1000u32);
|
|
let amount_in = (&max_input * &percentage_biguint) / &thousand;
|
|
|
|
// Skip if amount is zero
|
|
if amount_in.is_zero() {
|
|
info!("Amount in multiplied by percentage {percentage} is zero. Skipping pool {id}.");
|
|
continue;
|
|
}
|
|
|
|
let amount_out_result = state
|
|
.get_amount_out(amount_in.clone(), token_in, token_out)
|
|
.into_diagnostic()
|
|
.wrap_err(format!(
|
|
"Error calculating amount out for Pool {id:?} at {:.1}% with input of {amount_in} {}.",
|
|
percentage * 100.0,
|
|
token_in.symbol,
|
|
))?;
|
|
|
|
info!(
|
|
"Simulated amount out for trading {:.1}% of max: ({} {} -> {} {}) (gas: {})",
|
|
percentage * 100.0,
|
|
amount_in,
|
|
token_in.symbol,
|
|
amount_out_result.amount,
|
|
token_out.symbol,
|
|
amount_out_result.gas
|
|
);
|
|
|
|
// Only execute for components that should have execution
|
|
if skip_execution.contains(id) {
|
|
info!("Skipping execution for component {id}");
|
|
continue;
|
|
}
|
|
|
|
let protocol_component = update.new_pairs.get(id).unwrap();
|
|
|
|
let (calldata, solution) = encode_swap(
|
|
protocol_component,
|
|
&token_in.address,
|
|
&token_out.address,
|
|
&amount_in,
|
|
&amount_out_result.amount,
|
|
)?;
|
|
|
|
info!("Simulating swap at historical block {}", block.number());
|
|
// Simulate the trade using debug_traceCall with overwrites
|
|
let execution_amount_out =
|
|
self.runtime
|
|
.block_on(execution::simulate_trade_with_eth_call(
|
|
&self.rpc_provider,
|
|
&calldata,
|
|
&solution,
|
|
&block,
|
|
));
|
|
|
|
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
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate that the token balances of the components match the values
|
|
/// on-chain, extracted by querying the token balances using a node.
|
|
fn validate_token_balances(
|
|
&self,
|
|
component_tokens: &HashMap<String, Vec<Token>>,
|
|
protocol_states_by_id: &HashMap<String, ResponseProtocolState>,
|
|
stop_block: u64,
|
|
) -> miette::Result<()> {
|
|
for (id, component) in protocol_states_by_id.iter() {
|
|
let tokens = component_tokens.get(id);
|
|
if let Some(tokens) = tokens {
|
|
for token in tokens {
|
|
let mut balance: U256 = U256::from(0);
|
|
let bal = component.balances.get(&token.address);
|
|
if let Some(bal) = bal {
|
|
let bal = bal.clone().into();
|
|
balance = bytes_to_u256(bal);
|
|
}
|
|
|
|
info!(
|
|
"Validating token balance for component {} and token {}",
|
|
id, token.symbol
|
|
);
|
|
let token_address = bytes_to_address(&token.address).into_diagnostic()?;
|
|
let component_address =
|
|
Address::from_str(id.as_str()).expect("Failed to parse component address");
|
|
let node_balance =
|
|
self.runtime
|
|
.block_on(self.rpc_provider.get_token_balance(
|
|
token_address,
|
|
component_address,
|
|
stop_block,
|
|
))?;
|
|
if balance != node_balance {
|
|
return Err(miette!(
|
|
"Token balance mismatch for component {id} and token {}. Balance: {balance}, Node balance: {node_balance}",
|
|
token.symbol
|
|
));
|
|
}
|
|
info!(
|
|
"Token balance for component {} and token {} matches the expected value",
|
|
id, token.symbol
|
|
);
|
|
}
|
|
} else {
|
|
return Err(miette!("Couldn't find tokens for component {}", id,));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{collections::HashMap, env, str::FromStr};
|
|
|
|
use dotenv::dotenv;
|
|
use glob::glob;
|
|
use tycho_simulation::tycho_common::{dto::ResponseProtocolState, Bytes};
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_all_configs() {
|
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
|
let curr_dir = PathBuf::from(manifest_dir);
|
|
let parent_dir = curr_dir.parent().unwrap();
|
|
env::set_current_dir(parent_dir).expect("Failed to set working directory");
|
|
|
|
let pattern = "./substreams/*/integration_test.tycho.yaml";
|
|
let mut results = Vec::new();
|
|
|
|
if glob(pattern).unwrap().count() == 0 {
|
|
panic!("No integration_test.tycho.yaml files found in substreams/*/");
|
|
}
|
|
for entry in glob(pattern).unwrap() {
|
|
match entry {
|
|
Ok(path) => {
|
|
if !path.is_file() {
|
|
results.push(Err(format!("Path is not a file: {}", path.display())));
|
|
} else {
|
|
let result = TestRunner::parse_config(&path);
|
|
if let Err(e) = &result {
|
|
results.push(Err(format!(
|
|
"Failed to parse config at {}: {e:?}",
|
|
path.display(),
|
|
)));
|
|
} else {
|
|
results.push(Ok(()));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => results.push(Err(format!("Glob error: {e:?}"))),
|
|
}
|
|
}
|
|
|
|
let errors: Vec<_> = results
|
|
.iter()
|
|
.filter_map(|r| r.as_ref().err())
|
|
.collect();
|
|
if !errors.is_empty() {
|
|
for error in errors {
|
|
println!("{error}");
|
|
}
|
|
panic!("One or more config files failed to parse.");
|
|
}
|
|
}
|
|
|
|
fn get_mocked_runner() -> TestRunner {
|
|
dotenv().ok();
|
|
let rpc_url = env::var("RPC_URL").unwrap();
|
|
let current_dir = std::env::current_dir().unwrap();
|
|
TestRunner::new(
|
|
current_dir,
|
|
"test-protocol".to_string(),
|
|
None,
|
|
"".to_string(),
|
|
false,
|
|
false,
|
|
rpc_url,
|
|
)
|
|
.unwrap()
|
|
}
|
|
#[test]
|
|
fn test_token_balance_validation() {
|
|
let runner = get_mocked_runner();
|
|
// Setup mock data
|
|
let block_number = 21998530;
|
|
let token_bytes = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap();
|
|
let component_id = "0x787B8840100d9BaAdD7463f4a73b5BA73B00C6cA".to_string();
|
|
let token = Token::new(&token_bytes, "FAKE", 18, 0, &[], Chain::Ethereum.into(), 100);
|
|
|
|
let mut balances = HashMap::new();
|
|
let balance_bytes = Bytes::from(
|
|
U256::from_str("1070041574684539264153")
|
|
.unwrap()
|
|
.to_be_bytes::<32>(),
|
|
);
|
|
balances.insert(token_bytes.clone(), balance_bytes.clone());
|
|
let protocol_state = ResponseProtocolState {
|
|
component_id: component_id.clone(),
|
|
balances,
|
|
..Default::default()
|
|
};
|
|
|
|
let mut component_tokens = HashMap::new();
|
|
component_tokens.insert(component_id.clone(), vec![token]);
|
|
let mut protocol_states_by_id = HashMap::new();
|
|
protocol_states_by_id.insert(component_id.clone(), protocol_state.clone());
|
|
|
|
let result =
|
|
runner.validate_token_balances(&component_tokens, &protocol_states_by_id, block_number);
|
|
assert!(result.is_ok(), "Should pass when balance check is performed and balances match");
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_balance_validation_fails_on_mismatch() {
|
|
let runner = get_mocked_runner();
|
|
|
|
// Setup mock data
|
|
let block_number = 21998530;
|
|
let token_bytes = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap();
|
|
let component_id = "0x787B8840100d9BaAdD7463f4a73b5BA73B00C6cA".to_string();
|
|
let token = Token::new(&token_bytes, "FAKE", 18, 0, &[], Chain::Ethereum.into(), 100);
|
|
|
|
// Set expected balance to zero
|
|
let mut balances = HashMap::new();
|
|
let balance_bytes = Bytes::from(U256::from(0).to_be_bytes::<32>());
|
|
balances.insert(token_bytes.clone(), balance_bytes.clone());
|
|
let protocol_state = ResponseProtocolState {
|
|
component_id: component_id.clone(),
|
|
balances,
|
|
..Default::default()
|
|
};
|
|
|
|
let mut component_tokens = HashMap::new();
|
|
component_tokens.insert(component_id.clone(), vec![token]);
|
|
let mut protocol_states_by_id = HashMap::new();
|
|
protocol_states_by_id.insert(component_id.clone(), protocol_state.clone());
|
|
|
|
dotenv().ok();
|
|
let result =
|
|
runner.validate_token_balances(&component_tokens, &protocol_states_by_id, block_number);
|
|
assert!(
|
|
result.is_err(),
|
|
"Should fail when balance check is performed and balances do not match"
|
|
);
|
|
}
|
|
}
|