diff --git a/Cargo.lock b/Cargo.lock index 92d30564cc..2a6891aaf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6974,6 +6974,7 @@ dependencies = [ "alloy", "anyhow", "axum 0.8.8", + "balance-overrides", "base64 0.22.1", "bigdecimal", "bytes-hex", @@ -7008,6 +7009,7 @@ dependencies = [ "serde_with", "sha2", "shared", + "simulator", "solver", "solvers-dto", "tempfile", diff --git a/crates/configs/src/orderbook/mod.rs b/crates/configs/src/orderbook/mod.rs index 225bd89619..094464d9be 100644 --- a/crates/configs/src/orderbook/mod.rs +++ b/crates/configs/src/orderbook/mod.rs @@ -241,6 +241,7 @@ mod tests { active-order-competition-threshold = 10 unsupported-tokens = ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"] eip1271-skip-creation-validation = true + order-simulation-gas-limit = "123456789" [banned-users] addresses = ["0xdead000000000000000000000000000000000000"] diff --git a/crates/e2e/src/setup/colocation.rs b/crates/e2e/src/setup/colocation.rs index 54ccde72c2..10871c6373 100644 --- a/crates/e2e/src/setup/colocation.rs +++ b/crates/e2e/src/setup/colocation.rs @@ -77,6 +77,41 @@ uni-v3-node-url = "http://localhost:8545" } } +pub async fn start_baseline_solver_with_gas_simulation( + name: String, + account: TestAccount, + weth: Address, + base_tokens: Vec
, + max_hops: usize, + merge_solutions: bool, + settlement: Address, +) -> SolverEngine { + let encoded_base_tokens = encode_base_tokens(base_tokens.clone()); + let config_file = config_tmp_file(format!( + r#" +weth = "{weth:?}" +base-tokens = [{encoded_base_tokens}] +max-hops = {max_hops} +max-partial-attempts = 5 +native-token-price-estimation-amount = "100000000000000000" +uni-v3-node-url = "http://localhost:8545" +gas-simulation-node-url = "http://localhost:8545" +gas-simulation-settlement = "{settlement:?}" + "#, + )); + let endpoint = start_solver(config_file, "baseline".to_string()).await; + SolverEngine { + name, + endpoint, + account, + base_tokens, + merge_solutions, + haircut_bps: 0, + submission_keys: vec![], + forwarder_contract: None, + } +} + async fn start_solver(config_file: TempPath, solver_name: String) -> Url { let args = vec![ "solvers".to_string(), diff --git a/crates/e2e/tests/e2e/hooks.rs b/crates/e2e/tests/e2e/hooks.rs index b88eedec5e..a3f03e4a56 100644 --- a/crates/e2e/tests/e2e/hooks.rs +++ b/crates/e2e/tests/e2e/hooks.rs @@ -4,10 +4,17 @@ use { providers::Provider, }, app_data::Hook, + configs::{ + autopilot::native_price::NativePriceConfig as AutopilotNativePriceConfig, + native_price_estimators::{NativePriceEstimator, NativePriceEstimators}, + order_quoting::{ExternalSolver, OrderQuoting}, + test_util::TestDefault, + }, e2e::setup::{ OnchainComponents, Services, TIMEOUT, + colocation, onchain_components, run_test, safe::Safe, @@ -55,6 +62,12 @@ async fn local_node_quote_verification() { run_test(quote_verification).await; } +#[tokio::test] +#[ignore] +async fn local_node_tx_gas_simulation() { + run_test(tx_gas_simulation).await; +} + async fn gas_limit(web3: Web3) { let mut onchain = OnchainComponents::deploy(web3).await; @@ -626,3 +639,178 @@ async fn quote_verification(web3: Web3) { // sell tokens with a pre-hook assert!(quote.verified); } + +/// Verifies that enabling `gas-simulation-node-url` on the baseline solver +/// produces a higher `gas_amount` in the quote response than the static +/// estimator, because simulation captures the gas cost of order hooks. +async fn tx_gas_simulation(web3: Web3) { + let mut onchain = OnchainComponents::deploy(web3.clone()).await; + + let [solver] = onchain.make_solvers(1u64.eth()).await; + let [trader] = onchain.make_accounts(1u64.eth()).await; + + let [token] = onchain + .deploy_tokens_with_weth_uni_v2_pools(100_000u64.eth(), 100_000u64.eth()) + .await; + + token.mint(trader.address(), 5u64.eth()).await; + token + .approve(onchain.contracts().allowance, 5u64.eth()) + .from(trader.address()) + .send_and_watch() + .await + .unwrap(); + + let counter = contracts::alloy::test::Counter::Instance::deploy(web3.provider.clone()) + .await + .unwrap(); + let pre_call = counter.incrementCounter("pre".to_string()); + let pre_gas = pre_call.estimate_gas().await.unwrap(); + let pre_hook = Hook { + target: *counter.address(), + call_data: pre_call.calldata().to_vec(), + gas_limit: pre_gas, + }; + let post_call = counter.incrementCounter("post".to_string()); + let post_gas = post_call.estimate_gas().await.unwrap(); + let post_hook = Hook { + target: *counter.address(), + call_data: post_call.calldata().to_vec(), + gas_limit: post_gas, + }; + + let quote_request = OrderQuoteRequest { + from: trader.address(), + sell_token: *token.address(), + buy_token: *onchain.contracts().weth.address(), + side: OrderQuoteSide::Sell { + sell_amount: SellAmount::BeforeFee { + value: NonZeroU256::try_from(5u64.eth()).unwrap(), + }, + }, + app_data: OrderCreationAppData::Full { + full: json!({ + "metadata": { + "hooks": { + "pre": [pre_hook], + "post": [post_hook], + }, + }, + }) + .to_string(), + }, + ..Default::default() + }; + + let solver_address = solver.address(); + let settlement = *onchain.contracts().gp_settlement.address(); + let weth = *onchain.contracts().weth.address(); + + // Phase 1: quote without gas simulation. + let solver_no_sim = colocation::start_baseline_solver( + "test_quoter".to_string(), + solver.clone(), + weth, + vec![], + 2, + false, + ) + .await; + let driver_no_sim = colocation::start_driver( + onchain.contracts(), + vec![solver_no_sim], + colocation::LiquidityProvider::UniswapV2, + false, + ); + + let services = Services::new(&onchain).await; + services + .start_api(configs::orderbook::Configuration { + order_quoting: OrderQuoting::test_with_drivers(vec![ExternalSolver::new( + "test_quoter", + "http://localhost:11088/test_quoter", + )]), + ..configs::orderbook::Configuration::test_default() + }) + .await; + services + .start_autopilot( + None, + configs::autopilot::Configuration { + native_price_estimation: AutopilotNativePriceConfig { + estimators: NativePriceEstimators::new(vec![vec![ + NativePriceEstimator::driver( + "test_quoter".to_string(), + "http://localhost:11088/test_quoter".parse().unwrap(), + ), + ]]), + ..AutopilotNativePriceConfig::test_default() + }, + ..configs::autopilot::Configuration::test("test_quoter", solver_address) + }, + ) + .await; + + wait_for_condition(TIMEOUT, || async { + reqwest::get("http://localhost:11088/test_quoter/healthz") + .await + .is_ok() + }) + .await + .expect("driver (no sim) did not start in time"); + + let gas_no_sim = services + .submit_quote("e_request) + .await + .unwrap() + .quote + .gas_amount; + + // Phase 2: replace driver with one that has gas simulation enabled. + driver_no_sim.abort(); + driver_no_sim.await.ok(); + + let solver_with_sim = colocation::start_baseline_solver_with_gas_simulation( + "test_quoter".to_string(), + solver, + weth, + vec![], + 2, + false, + settlement, + ) + .await; + let _driver_with_sim = colocation::start_driver( + onchain.contracts(), + vec![solver_with_sim], + colocation::LiquidityProvider::UniswapV2, + false, + ); + + wait_for_condition(TIMEOUT, || async { + reqwest::get("http://localhost:11088/test_quoter/healthz") + .await + .is_ok() + }) + .await + .expect("driver (with sim) did not start in time"); + + let gas_with_sim = services + .submit_quote("e_request) + .await + .unwrap() + .quote + .gas_amount; + + assert!( + gas_with_sim > gas_no_sim, + "simulated gas {gas_with_sim} should exceed static estimate {gas_no_sim}" + ); + let hooks_gas = bigdecimal::BigDecimal::from(pre_gas + post_gas); + assert!( + gas_with_sim >= hooks_gas, + "simulated gas {gas_with_sim} should be at least the sum of hook gas limits" + ); + + println!("gas_no_sim={gas_no_sim}, gas_with_sim={gas_with_sim}"); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index d62f1b9c67..616e404bcd 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -4,6 +4,7 @@ pub mod debug_report; pub mod fee_policy; pub mod interaction; pub mod order; +pub mod order_simulator; pub mod quote; pub mod signature; pub mod solver_competition; diff --git a/crates/model/src/order_simulator.rs b/crates/model/src/order_simulator.rs new file mode 100644 index 0000000000..1618b65436 --- /dev/null +++ b/crates/model/src/order_simulator.rs @@ -0,0 +1,114 @@ +use { + alloy_primitives::{Address, B256, U256, map::B256Map}, + serde::{Deserialize, Serialize}, + serde_with::serde_as, + std::collections::HashMap, +}; + +/// Tenderly API simulation request +/// https://docs.tenderly.co/reference/api#/operations/simulateTransaction#request-body +#[serde_as] +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct TenderlyRequest { + /// ID of the network on which the simulation is being run. + pub network_id: String, + /// Number of the block to be used for the simulation. + #[serde(skip_serializing_if = "Option::is_none")] + pub block_number: Option, + /// Index of the transaction within the block. + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_index: Option, + /// Address initiating the transaction. + pub from: Address, + /// The recipient address of the transaction. + pub to: Address, + /// Encoded contract method call data. + #[serde_as(as = "serde_ext::Hex")] + pub input: Vec, + /// Amount of gas provided for the simulation. + #[serde(skip_serializing_if = "Option::is_none")] + pub gas: Option, + /// String representation of a number that represents price of the gas in + /// Wei. + #[serde(skip_serializing_if = "Option::is_none")] + pub gas_price: Option, + /// Amount of Ether (in Wei) sent along with the transaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub simulation_type: Option, + /// Flag indicating whether to save the simulation in dashboard UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub save: Option, + /// Flag indicating whether to save failed simulation in dashboard UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub save_if_fails: Option, + /// Flag that enables returning the access list in a response. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_access_list: Option, + /// Overrides for a given contract. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_objects: Option>, + /// EIP-2930 access list used by the transaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub access_list: Option>, +} + +/// EIP-2930 access list used by the transaction. +/// https://docs.tenderly.co/reference/api#/operations/simulateTransaction#response-body:~:text=0x-,access_list,-array%20or%20null +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct AccessListItem { + /// Accessed address + pub address: Address, + /// Accessed storage keys + #[serde(default)] + pub storage_keys: Vec, +} + +/// Overrides for a given contract. In this mapping, the key is the contract +/// address, and the value is an object that contains overrides of nonce, code, +/// balance, or state. https://docs.tenderly.co/reference/api#/operations/simulateTransaction#response-body:~:text=null%2C%22uncles%22%3Anull%7D-,state_objects,-dictionary%5Bstring%2C%20object +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub struct StateObject { + /// Fake balance to set for the account before executing the call. + #[serde(skip_serializing_if = "Option::is_none")] + pub balance: Option, + + /// Fake EVM bytecode to inject into the account before executing the call. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + + /// Fake key-value mapping to override **individual** slots in the account + /// storage before executing the call. + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option>, +} + +/// Opt for quick, abi, or full simulation API mode. +/// full (default): Detailed decoded output — call trace, function +/// inputs/outputs, state diffs, and logs with Solidity types. +/// +/// quick: Raw, +/// minimal output only. Fastest option; no decoding. +/// +/// abi: Decoded function +/// inputs/outputs and logs, but no state diffs. Middle ground between quick and +/// full. +/// +/// https://docs.tenderly.co/reference/api#/operations/simulateTransaction#response-body:~:text=true-,simulation_type,-string +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SimulationType { + Full, + Quick, + Abi, +} + +/// The result of Order simulation, contains the error (if any) +/// and full Tenderly API request that can be used to resimulate +/// and debug using Tenderly +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct OrderSimulation { + pub tenderly_request: TenderlyRequest, + pub error: Option, +} diff --git a/crates/simulator/src/encoding.rs b/crates/simulator/src/encoding.rs index c82dfc5cc1..e259b79fa3 100644 --- a/crates/simulator/src/encoding.rs +++ b/crates/simulator/src/encoding.rs @@ -28,6 +28,9 @@ pub type EncodedTrade = ( Bytes, // signature ); +// TODO: Change Vec into VecDeque for easy sandwitching of custom pre, main, +// post interaction at the callsite. +// This can't work elegantly until `extend_front` of VecDeque becomes stabilized #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Interactions { pub pre: Vec, diff --git a/crates/simulator/src/tenderly/dto.rs b/crates/simulator/src/tenderly/dto.rs index 0800e57281..38d6ad5187 100644 --- a/crates/simulator/src/tenderly/dto.rs +++ b/crates/simulator/src/tenderly/dto.rs @@ -1,6 +1,7 @@ use { alloy_primitives::{Address, B256, U256, map::B256Map}, eth_domain_types as eth, + model::order_simulator::{self, TenderlyRequest}, serde::{Deserialize, Serialize}, serde_with::serde_as, std::collections::HashMap, @@ -55,6 +56,105 @@ pub struct Request { pub access_list: Option, } +impl From for TenderlyRequest { + fn from(value: Request) -> Self { + Self { + network_id: value.network_id, + block_number: value.block_number, + transaction_index: value.transaction_index, + from: value.from, + to: value.to, + input: value.input, + gas: value.gas, + gas_price: value.gas_price, + value: value.value, + simulation_type: value.simulation_type.map(|kind| match kind { + SimulationType::Full => order_simulator::SimulationType::Full, + SimulationType::Quick => order_simulator::SimulationType::Quick, + SimulationType::Abi => order_simulator::SimulationType::Abi, + }), + save: value.save, + save_if_fails: value.save_if_fails, + generate_access_list: value.generate_access_list, + state_objects: value.state_objects.map(|state_objects| { + state_objects + .into_iter() + .map(|(key, state_object)| { + ( + key, + order_simulator::StateObject { + balance: state_object.balance, + code: state_object.code, + storage: state_object.storage, + }, + ) + }) + .collect() + }), + access_list: value.access_list.map(|access_list| { + access_list + .0 + .into_iter() + .map(|item| order_simulator::AccessListItem { + address: item.address, + storage_keys: item.storage_keys, + }) + .collect() + }), + } + } +} + +impl From for Request { + fn from(value: TenderlyRequest) -> Self { + Self { + network_id: value.network_id, + block_number: value.block_number, + transaction_index: value.transaction_index, + from: value.from, + to: value.to, + input: value.input, + gas: value.gas, + gas_price: value.gas_price, + value: value.value, + simulation_type: value.simulation_type.map(|kind| match kind { + order_simulator::SimulationType::Full => SimulationType::Full, + order_simulator::SimulationType::Quick => SimulationType::Quick, + order_simulator::SimulationType::Abi => SimulationType::Abi, + }), + save: value.save, + save_if_fails: value.save_if_fails, + generate_access_list: value.generate_access_list, + state_objects: value.state_objects.map(|state_objects| { + state_objects + .into_iter() + .map(|(key, state_object)| { + ( + key, + StateObject { + balance: state_object.balance, + code: state_object.code, + storage: state_object.storage, + }, + ) + }) + .collect() + }), + access_list: value.access_list.map(|access_list| { + AccessList( + access_list + .into_iter() + .map(|item| AccessListItem { + address: item.address, + storage_keys: item.storage_keys, + }) + .collect(), + ) + }), + } + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub struct Response { pub transaction: Transaction, diff --git a/crates/solvers/Cargo.toml b/crates/solvers/Cargo.toml index 513a8fb8e2..a26b445a33 100644 --- a/crates/solvers/Cargo.toml +++ b/crates/solvers/Cargo.toml @@ -52,6 +52,9 @@ tower = { workspace = true } tower-http = { workspace = true, features = ["limit", "trace"] } url = { workspace = true, features = ["serde"] } +balance-overrides = { workspace = true } +simulator = { workspace = true } + # TODO Once solvers are ported and E2E tests set up, slowly migrate code and # remove/re-evaluate these dependencies. anyhow = { workspace = true } diff --git a/crates/solvers/src/api/routes/solve/dto/auction.rs b/crates/solvers/src/api/routes/solve/dto/auction.rs index 6b4116d5a8..246c21343a 100644 --- a/crates/solvers/src/api/routes/solve/dto/auction.rs +++ b/crates/solvers/src/api/routes/solve/dto/auction.rs @@ -78,6 +78,24 @@ pub fn into_domain(auction: Auction) -> Result { data: w.data.clone(), }) .collect(), + pre_interactions: order + .pre_interactions + .iter() + .map(|i| eth::Interaction { + target: i.target, + value: eth::Ether(i.value), + calldata: i.call_data.clone(), + }) + .collect(), + post_interactions: order + .post_interactions + .iter() + .map(|i| eth::Interaction { + target: i.target, + value: eth::Ether(i.value), + calldata: i.call_data.clone(), + }) + .collect(), }) .collect(), liquidity: auction diff --git a/crates/solvers/src/domain/eth/mod.rs b/crates/solvers/src/domain/eth/mod.rs index 8b976fd0bb..dd74b1ca4f 100644 --- a/crates/solvers/src/domain/eth/mod.rs +++ b/crates/solvers/src/domain/eth/mod.rs @@ -91,7 +91,7 @@ pub struct Tx { /// An arbitrary ethereum interaction that is required for the settlement /// execution. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Interaction { pub target: Address, pub value: Ether, diff --git a/crates/solvers/src/domain/order.rs b/crates/solvers/src/domain/order.rs index 97b78c961c..2a2b732841 100644 --- a/crates/solvers/src/domain/order.rs +++ b/crates/solvers/src/domain/order.rs @@ -18,6 +18,8 @@ pub struct Order { pub partially_fillable: bool, pub flashloan_hint: Option, pub wrappers: Vec, + pub pre_interactions: Vec, + pub post_interactions: Vec, } impl Order { diff --git a/crates/solvers/src/domain/solver/baseline.rs b/crates/solvers/src/domain/solver/baseline.rs index 80c4ff52f6..042e440dd5 100644 --- a/crates/solvers/src/domain/solver/baseline.rs +++ b/crates/solvers/src/domain/solver/baseline.rs @@ -16,7 +16,7 @@ use { order::{self, Order, Side}, solution, }, - infra::metrics, + infra::{metrics, tx_gas}, }, alloy::primitives::U256, reqwest::Url, @@ -38,6 +38,7 @@ pub struct Config { pub solution_gas_offset: eth::SignedGas, pub native_token_price_estimation_amount: eth::U256, pub uni_v3_node_url: Option, + pub tx_gas_estimator: Option>, } struct Inner { @@ -72,6 +73,10 @@ struct Inner { /// If provided, the solver can rely on Uniswap V3 LPs uni_v3_quoter_v2: Option>, + + /// If provided, gas is estimated by simulating the full settlement + /// transaction instead of using static per-liquidity-source costs. + tx_gas_estimator: Option>, } impl Solver { @@ -99,6 +104,7 @@ impl Solver { solution_gas_offset: config.solution_gas_offset, native_token_price_estimation_amount: config.native_token_price_estimation_amount, uni_v3_quoter_v2, + tx_gas_estimator: config.tx_gas_estimator, })) } @@ -200,12 +206,20 @@ impl Inner { Side::Buy => (order.buy, order.sell), }; output.amount = input.amount; + let gas = if let Some(ref est) = self.tx_gas_estimator { + est.estimate(&order, input, output) + .await + .filter(|g| !g.0.is_zero()) + .unwrap_or_else(|| eth::Gas(U256::ZERO) + self.solution_gas_offset) + } else { + eth::Gas(U256::ZERO) + self.solution_gas_offset + }; solution::Single { order: order.clone(), input, output, interactions: Vec::default(), - gas: eth::Gas(U256::ZERO) + self.solution_gas_offset, + gas, wrappers, } } else { @@ -226,8 +240,17 @@ impl Inner { )) }) .collect(); - let gas = route.gas() + self.solution_gas_offset; - let mut output = route.output(); + let route_input = route.input(); + let route_output = route.output(); + let gas = if let Some(ref est) = self.tx_gas_estimator { + est.estimate(&order, route_input, route_output) + .await + .filter(|g| !g.0.is_zero()) + .unwrap_or_else(|| route.gas() + self.solution_gas_offset) + } else { + route.gas() + self.solution_gas_offset + }; + let mut output = route_output; // The baseline solver generates a path with swapping // for exact output token amounts. This leads to @@ -241,7 +264,7 @@ impl Inner { solution::Single { order: order.clone(), - input: route.input(), + input: route_input, output, interactions, gas, diff --git a/crates/solvers/src/infra/config/baseline.rs b/crates/solvers/src/infra/config/baseline.rs index f2ce0053b0..adc3847b42 100644 --- a/crates/solvers/src/infra/config/baseline.rs +++ b/crates/solvers/src/infra/config/baseline.rs @@ -1,13 +1,15 @@ use { crate::{ domain::{eth, solver}, - infra::contracts, + infra::{contracts, tx_gas}, }, + balance_overrides::BalanceOverrides, chain::Chain, price_estimation::gas::SETTLEMENT_OVERHEAD, reqwest::Url, serde::Deserialize, - std::path::Path, + simulator::swap_simulator::SwapSimulator, + std::{path::Path, sync::Arc}, tokio::fs, }; @@ -48,6 +50,16 @@ struct Config { /// If this is configured the solver will also use the Uniswap V3 liquidity /// sources that rely on RPC request. uni_v3_node_url: Option, + + /// If set, the solver will simulate each solution's full settlement + /// transaction to obtain an accurate gas estimate that includes order + /// hook costs. Requires `chain-id` to be set. + gas_simulation_node_url: Option, + + /// Explicit settlement contract address for gas simulation. When provided, + /// `chain-id` is not required for gas simulation. Useful for local test + /// environments where contracts are deployed at non-canonical addresses. + gas_simulation_settlement: Option, } /// Load the driver configuration from a TOML file. @@ -73,6 +85,38 @@ pub async fn load(path: &Path) -> solver::Config { ), }; + let tx_gas_estimator = if let Some(url) = config.gas_simulation_node_url { + let settlement_addr = if let Some(addr) = config.gas_simulation_settlement { + addr + } else { + let chain_id = config.chain_id.expect( + "invalid configuration: `chain-id` is required when `gas-simulation-node-url` \ + is set and `gas-simulation-settlement` is not provided", + ); + contracts::Contracts::for_chain(chain_id).settlement + }; + let web3 = ethrpc::web3(Default::default(), &url, Some("tx-gas")); + #[allow(deprecated)] + let current_block = + ethrpc::block_stream::current_block_stream(url.clone(), Default::default()) + .await + .expect("failed to create block stream for tx gas estimator"); + let balance_overrides = Arc::new(BalanceOverrides::new(web3.clone())); + let swap_simulator = SwapSimulator::new( + balance_overrides, + settlement_addr, + weth.0, + current_block, + web3, + 15_000_000u64, + ) + .await + .expect("failed to create swap simulator for tx gas estimator"); + Some(Arc::new(tx_gas::TxGasEstimator::new(swap_simulator))) + } else { + None + }; + solver::Config { weth, base_tokens: config @@ -85,6 +129,7 @@ pub async fn load(path: &Path) -> solver::Config { solution_gas_offset: config.solution_gas_offset.into(), native_token_price_estimation_amount: config.native_token_price_estimation_amount, uni_v3_node_url: config.uni_v3_node_url, + tx_gas_estimator, } } diff --git a/crates/solvers/src/infra/mod.rs b/crates/solvers/src/infra/mod.rs index e3585bfc7e..1b6dcdd130 100644 --- a/crates/solvers/src/infra/mod.rs +++ b/crates/solvers/src/infra/mod.rs @@ -4,3 +4,4 @@ pub mod config; pub mod contracts; pub mod dex; pub mod metrics; +pub mod tx_gas; diff --git a/crates/solvers/src/infra/tx_gas.rs b/crates/solvers/src/infra/tx_gas.rs new file mode 100644 index 0000000000..251445c96a --- /dev/null +++ b/crates/solvers/src/infra/tx_gas.rs @@ -0,0 +1,163 @@ +use { + crate::domain::{eth, order}, + alloy::{ + primitives::{Address, U256}, + providers::Provider, + rpc::types::state::{AccountOverride, StateOverride}, + }, + balance_overrides::BalanceOverrideRequest, + contracts::alloy::support::{AnyoneAuthenticator, Trader}, + model::order::{BuyTokenDestination, OrderKind, SellTokenSource}, + number::nonzero::NonZeroU256, + simulator::{ + encoding::WrapperCall, + swap_simulator::{Query, SwapSimulator, TradeEncoding}, + }, +}; + +pub struct TxGasEstimator { + simulator: SwapSimulator, +} + +impl TxGasEstimator { + pub fn new(simulator: SwapSimulator) -> Self { + Self { simulator } + } + + /// Estimates the gas for settling an order by simulating the full + /// settlement transaction (including order hooks). Returns `None` if + /// simulation fails, in which case the caller should fall back to static + /// gas estimation. + pub async fn estimate( + &self, + order: &order::Order, + input: eth::Asset, + output: eth::Asset, + ) -> Option { + let sell_amount = NonZeroU256::new(input.amount)?; + let solver = Address::random(); + let owner = order.owner(); + + let query = Query { + sell_token: input.token.0, + sell_amount, + buy_token: output.token.0, + buy_amount: output.amount, + kind: match order.side { + order::Side::Sell => OrderKind::Sell, + order::Side::Buy => OrderKind::Buy, + }, + receiver: owner, + sell_token_source: SellTokenSource::Erc20, + buy_token_destination: BuyTokenDestination::Erc20, + from: owner, + tx_origin: None, + solver, + tokens: vec![input.token.0, output.token.0], + clearing_prices: vec![output.amount, input.amount], + wrappers: order + .wrappers + .iter() + .map(|w| WrapperCall { + address: w.address, + data: w.data.clone().into(), + }) + .collect(), + }; + + let mut swap = self + .simulator + .fake_swap(&query, TradeEncoding::Simple) + .await + .ok()?; + + // Inject order hooks before/after existing interactions. + let pre = order.pre_interactions.iter().map(encode_interaction); + swap.settlement.interactions.pre = pre + .chain(std::mem::take(&mut swap.settlement.interactions.pre)) + .collect(); + swap.settlement + .interactions + .post + .extend(order.post_interactions.iter().map(encode_interaction)); + + let state_overrides = self.prepare_state_overrides(solver, owner, output).await?; + swap.overrides.extend(state_overrides); + + // simulate_settle_call gives us back the encoded tx + overrides; + // re-use those to call eth_estimateGas. + let sim = self + .simulator + .simulate_settle_call_on_latest(swap) + .await + .ok()?; + let block = *self.simulator.current_block.borrow(); + let gas: u64 = self + .simulator + .web3 + .provider + .estimate_gas(sim.tx) + .overrides(sim.overrides) + .block(block.number.into()) + .await + .ok()?; + + Some(eth::Gas(U256::from(gas))) + } + + async fn prepare_state_overrides( + &self, + solver: Address, + owner: Address, + output: eth::Asset, + ) -> Option { + let mut overrides = StateOverride::default(); + + let authenticator = self + .simulator + .settlement + .authenticator() + .call() + .await + .ok()?; + overrides.insert( + authenticator, + AccountOverride { + code: Some(AnyoneAuthenticator::AnyoneAuthenticator::DEPLOYED_BYTECODE.clone()), + ..Default::default() + }, + ); + overrides.insert( + solver, + AccountOverride { + balance: Some(U256::MAX / U256::from(2)), + ..Default::default() + }, + ); + overrides.insert( + owner, + AccountOverride { + code: Some(Trader::Trader::DEPLOYED_BYTECODE.clone()), + ..Default::default() + }, + ); + if let Some((token, balance_override)) = self + .simulator + .balance_overrides + .state_override(BalanceOverrideRequest { + token: output.token.0, + holder: *self.simulator.settlement.address(), + amount: output.amount, + }) + .await + { + overrides.insert(token, balance_override); + } + + Some(overrides) + } +} + +fn encode_interaction(i: ð::Interaction) -> simulator::encoding::EncodedInteraction { + (i.target, i.value.0, i.calldata.clone().into()) +}