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())
+}