diff --git a/Cargo.lock b/Cargo.lock index 02a5c983e4..32272c60e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7887,6 +7887,7 @@ dependencies = [ "alloy", "anyhow", "axum 0.8.8", + "balance-overrides", "base64 0.22.1", "bigdecimal", "bytes-hex", @@ -7921,6 +7922,7 @@ dependencies = [ "serde_with", "sha2", "shared", + "simulator", "solver", "solvers-dto", "tempfile", diff --git a/crates/driver/src/domain/quote.rs b/crates/driver/src/domain/quote.rs index 4acc4946ba..bf29b35020 100644 --- a/crates/driver/src/domain/quote.rs +++ b/crates/driver/src/domain/quote.rs @@ -16,7 +16,7 @@ use { util, }, chrono::Utc, - eth_domain_types as eth, + eth_domain_types::{self as eth, Address}, std::collections::{HashMap, HashSet}, }; @@ -118,6 +118,9 @@ pub struct Order { pub amount: order::TargetAmount, pub side: order::Side, pub deadline: chrono::DateTime, + pub owner: Address, + pub pre_interactions: Vec, + pub post_interactions: Vec, } impl Order { @@ -177,7 +180,7 @@ impl Order { competition::Auction::new( None, vec![competition::Order { - uid: Default::default(), + uid: competition::order::Uid::from_parts(eth::B256::ZERO, self.owner, u32::MAX), receiver: None, created: u32::try_from(Utc::now().timestamp()) .unwrap_or(u32::MIN) @@ -193,14 +196,14 @@ impl Order { }, app_data: Default::default(), partial: competition::order::Partial::No, - pre_interactions: Default::default(), - post_interactions: Default::default(), + pre_interactions: self.pre_interactions.clone(), + post_interactions: self.post_interactions.clone(), sell_token_balance: competition::order::SellTokenBalance::Erc20, buy_token_balance: competition::order::BuyTokenBalance::Erc20, signature: competition::order::Signature { scheme: competition::order::signature::Scheme::Eip1271, data: Default::default(), - signer: Default::default(), + signer: self.owner, }, protocol_fees: Default::default(), quote: Default::default(), diff --git a/crates/driver/src/infra/api/routes/quote/dto/mod.rs b/crates/driver/src/infra/api/routes/quote/dto/mod.rs index 47d8d9588e..53ffa45e8c 100644 --- a/crates/driver/src/infra/api/routes/quote/dto/mod.rs +++ b/crates/driver/src/infra/api/routes/quote/dto/mod.rs @@ -2,6 +2,6 @@ mod order; mod quote; pub use { - order::{Error as OrderError, Order}, + order::{Error as OrderError, Order, PostOrder}, quote::Quote, }; diff --git a/crates/driver/src/infra/api/routes/quote/dto/order.rs b/crates/driver/src/infra/api/routes/quote/dto/order.rs index 64449b6a4b..5abe26a870 100644 --- a/crates/driver/src/infra/api/routes/quote/dto/order.rs +++ b/crates/driver/src/infra/api/routes/quote/dto/order.rs @@ -1,5 +1,5 @@ use { - crate::domain::{competition, quote}, + crate::domain::{self, competition, quote}, eth_domain_types as eth, serde::Deserialize, serde_with::serde_as, @@ -7,6 +7,19 @@ use { impl Order { pub fn into_domain(self) -> quote::Order { + self.into_domain_with_interactions( + Default::default(), + Default::default(), + Default::default(), + ) + } + + pub fn into_domain_with_interactions( + self, + owner: eth::Address, + pre_interactions: Vec, + post_interactions: Vec, + ) -> quote::Order { quote::Order { tokens: quote::Tokens::new(self.sell_token.into(), self.buy_token.into()), amount: self.amount.into(), @@ -15,6 +28,9 @@ impl Order { Kind::Buy => competition::order::Side::Buy, }, deadline: self.deadline, + owner, + pre_interactions, + post_interactions, } } } @@ -38,6 +54,61 @@ enum Kind { Buy, } +/// Order body for the POST /quote endpoint, which additionally allows +/// specifying pre/post interactions. +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PostOrder { + #[serde(flatten)] + pub order: Order, + #[serde(default)] + pub from: eth::Address, + #[serde(default)] + pub interactions: Interactions, +} + +impl PostOrder { + pub fn into_domain(self) -> quote::Order { + self.order.into_domain_with_interactions( + self.from, + self.interactions.pre.into_iter().map(Into::into).collect(), + self.interactions.post.into_iter().map(Into::into).collect(), + ) + } +} + +#[serde_as] +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Interactions { + #[serde(default)] + pub pre: Vec, + #[serde(default)] + pub post: Vec, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Interaction { + pub target: eth::Address, + #[serde_as(as = "serde_ext::U256")] + pub value: eth::U256, + #[serde_as(as = "serde_ext::Hex")] + pub call_data: Vec, +} + +impl From for domain::Interaction { + fn from(i: Interaction) -> Self { + Self { + target: i.target, + value: i.value.into(), + call_data: i.call_data.into(), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("received an order with identical buy and sell tokens")] diff --git a/crates/driver/src/infra/api/routes/quote/mod.rs b/crates/driver/src/infra/api/routes/quote/mod.rs index 5fb791b2ac..bc5e53af9a 100644 --- a/crates/driver/src/infra/api/routes/quote/mod.rs +++ b/crates/driver/src/infra/api/routes/quote/mod.rs @@ -11,15 +11,30 @@ mod dto; pub use dto::OrderError; pub(in crate::infra::api) fn quote(router: axum::Router) -> axum::Router { - router.route("/quote", axum::routing::get(route)) + router + .route("/quote", axum::routing::get(get)) + .route("/quote", axum::routing::post(post)) } -async fn route( +async fn get( state: axum::extract::State, LoggingQuery(order): LoggingQuery, +) -> Result, (axum::http::StatusCode, axum::Json)> { + execute(state, order.into_domain()).await +} + +async fn post( + state: axum::extract::State, + axum::Json(order): axum::Json, +) -> Result, (axum::http::StatusCode, axum::Json)> { + execute(state, order.into_domain()).await +} + +async fn execute( + state: axum::extract::State, + order: crate::domain::quote::Order, ) -> Result, (axum::http::StatusCode, axum::Json)> { let handle_request = async { - let order = order.into_domain(); observe::quoting(&order); let quote = order .quote( 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 8b17ec3af4..e6940c661c 100644 --- a/crates/e2e/tests/e2e/hooks.rs +++ b/crates/e2e/tests/e2e/hooks.rs @@ -4,10 +4,18 @@ use { providers::Provider, }, app_data::Hook, + price_estimation::trade_finding::external::dto, + 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 +63,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 +640,211 @@ 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 gas_amount = 1_000_000; + let gas_hog = contracts::test::GasHog::Instance::deploy(web3.provider.clone()) + .await + .unwrap(); + let call = gas_hog.isValidSignature( + Default::default(), + U256::from(gas_amount).to_be_bytes::<32>().to_vec().into(), + ); + let gas = call.estimate_gas().await.unwrap(); + let pre_hook = Hook { + target: *gas_hog.address(), + call_data: call.calldata().to_vec(), + gas_limit: gas, + }; + let post_hook = Hook { + target: *gas_hog.address(), + call_data: call.calldata().to_vec(), + gas_limit: 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() + }, + order_quoting: OrderQuoting::test_with_drivers(vec![ExternalSolver::new( + "test_quoter", + "http://localhost:11088/test_quoter", + )]), + ..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 post_order = dto::PostOrder { + sell_token: *token.address(), + buy_token: *onchain.contracts().weth.address(), + amount: 5u64.eth(), + kind: OrderKind::Sell, + deadline: chrono::Utc::now() + std::time::Duration::from_secs(30), + from: trader.address(), + interactions: dto::Interactions { + pre: vec![dto::Interaction { + target: pre_hook.target, + value: U256::ZERO, + call_data: pre_hook.call_data.clone(), + }], + post: vec![dto::Interaction { + target: post_hook.target, + value: U256::ZERO, + call_data: post_hook.call_data.clone(), + }], + }, + }; + let _quote_response = reqwest::Client::new() + .post("http://localhost:11088/test_quoter/quote") + .json(&post_order) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + 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(gas * 2); + assert!( + gas_with_sim >= hooks_gas, + "simulated gas {gas_with_sim} should be at least the sum of hook gas limits" + ); +} diff --git a/crates/price-estimation/src/trade_finding/external.rs b/crates/price-estimation/src/trade_finding/external.rs index e49dd5403d..7f2a30f8b9 100644 --- a/crates/price-estimation/src/trade_finding/external.rs +++ b/crates/price-estimation/src/trade_finding/external.rs @@ -65,12 +65,35 @@ impl ExternalTradeFinder { /// the result into a Quote or Trade. async fn shared_query(&self, query: &Query) -> Result { let fut = move |query: &Query| { - let order = dto::Order { + let order = dto::PostOrder { sell_token: query.sell_token, buy_token: query.buy_token, amount: query.in_amount.get(), kind: query.kind, deadline: chrono::Utc::now() + query.timeout, + from: query.verification.from, + interactions: dto::Interactions { + pre: query + .verification + .pre_interactions + .iter() + .map(|i| dto::Interaction { + target: i.target, + value: i.value, + call_data: i.data.clone(), + }) + .collect(), + post: query + .verification + .post_interactions + .iter() + .map(|i| dto::Interaction { + target: i.target, + value: i.value, + call_data: i.data.clone(), + }) + .collect(), + }, }; let block_dependent = query.block_dependent; let id = observe::tracing::distributed::request_id::from_current_span(); @@ -81,11 +104,10 @@ impl ExternalTradeFinder { async move { let mut request = client - .get(quote_endpoint) + .post(quote_endpoint) .timeout(timeout) - .query(&order) + .json(&order) .headers(tracing_headers()) - .header(header::CONTENT_TYPE, "application/json") .header(header::ACCEPT, "application/json"); if block_dependent { @@ -378,13 +400,23 @@ pub mod dto { #[serde_as] #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] - pub struct Order { + pub struct PostOrder { pub sell_token: Address, pub buy_token: Address, #[serde_as(as = "HexOrDecimalU256")] pub amount: U256, pub kind: OrderKind, pub deadline: chrono::DateTime, + pub from: Address, + pub interactions: Interactions, + } + + #[serde_as] + #[derive(Clone, Debug, Default, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Interactions { + pub pre: Vec, + pub post: Vec, } #[serde_as] @@ -426,7 +458,7 @@ pub mod dto { } #[serde_as] - #[derive(Clone, Debug, Deserialize)] + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Interaction { pub target: Address, diff --git a/crates/shared/src/order_quoting.rs b/crates/shared/src/order_quoting.rs index 1cf936c550..e964773173 100644 --- a/crates/shared/src/order_quoting.rs +++ b/crates/shared/src/order_quoting.rs @@ -497,6 +497,8 @@ impl OrderQuoter { } => (trade_estimate.out_amount, buy_amount.get()), }; + tracing::error!("Gas estimate for quote {}", trade_estimate.gas); + let fee_parameters = FeeParameters { gas_amount: trade_estimate.gas as f64, gas_price: effective_gas_price as f64, @@ -595,10 +597,12 @@ impl OrderQuoting for OrderQuoter { &self, parameters: QuoteParameters, ) -> Result { + tracing::error!(?parameters, "computing quote"); let data = self.compute_quote_data(¶meters).await?; + tracing::error!(?data, "quote data"); let mut quote = Quote::new(Default::default(), data).with_additional_cost(parameters.additional_cost()); - + tracing::error!(?quote, "quote"); // Make sure to scale the sell and buy amounts for quotes for sell // amounts before fees. if let OrderQuoteSide::Sell { diff --git a/crates/simulator/src/encoding.rs b/crates/simulator/src/encoding.rs index 8c6d188c24..421ff28a89 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/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/boundary/baseline.rs b/crates/solvers/src/boundary/baseline.rs index 394d758e49..16af7f4270 100644 --- a/crates/solvers/src/boundary/baseline.rs +++ b/crates/solvers/src/boundary/baseline.rs @@ -145,7 +145,7 @@ impl<'a> Solver<'a> { token: eth::TokenAddress(buy_token), amount: buy_amount, }, - gas: eth::Gas(U256::from(liquidity.gas_cost().await)), + gas: reference_liquidity.gas, }); sell_token = buy_token; 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/solution.rs b/crates/solvers/src/domain/solution.rs index 373563b33a..d6d447d61d 100644 --- a/crates/solvers/src/domain/solution.rs +++ b/crates/solvers/src/domain/solution.rs @@ -135,6 +135,8 @@ impl Single { wrappers, } = self; + tracing::error!(?gas, "into_solution"); + if (order.sell.token, order.buy.token) != (input.token, output.token) { return None; } diff --git a/crates/solvers/src/domain/solver/baseline.rs b/crates/solvers/src/domain/solver/baseline.rs index 1ee13045e7..78683b816b 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, })) } @@ -155,6 +161,8 @@ impl Inner { self.uni_v3_quoter_v2.clone(), ); + tracing::error!(?auction, "Solving auction"); + for (i, order) in auction.orders.into_iter().enumerate() { let sell_token = order.sell.token; let sell_token_price = match auction.tokens.reference_price(&sell_token) { @@ -191,6 +199,7 @@ impl Inner { }; let compute_solution = async |request: Request| -> Option { + tracing::error!(?request, ?order, "Computing solution"); let wrappers = request.wrappers.clone(); let solution = if order.sell.token == order.buy.token { // When sell and buy tokens are the same, the solution does not require routing @@ -200,12 +209,21 @@ 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 + }; + tracing::error!(?gas, "Calcualated gas for sell=buy"); solution::Single { order: order.clone(), input, output, interactions: Vec::default(), - gas: eth::Gas(U256::ZERO) + self.solution_gas_offset, + gas, wrappers, } } else { @@ -226,8 +244,19 @@ 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()) + .map(|gas| gas + route.gas()) + .unwrap_or_else(|| route.gas() + self.solution_gas_offset) + } else { + route.gas() + self.solution_gas_offset + }; + tracing::error!(?gas, "Calculated gas"); + let mut output = route_output; // The baseline solver generates a path with swapping // for exact output token amounts. This leads to @@ -241,7 +270,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..a22134e5ba 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,40 @@ 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")); + // The WS variant requires a WebSocket URL; the polling fallback is + // acceptable here since gas simulation is best-effort. + #[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 +131,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..a3c150df26 --- /dev/null +++ b/crates/solvers/src/infra/tx_gas.rs @@ -0,0 +1,206 @@ +use { + crate::domain::{eth, order}, + alloy::{ + primitives::{Address, U256}, + providers::Provider, + rpc::types::state::{AccountOverride, StateOverride}, + }, + balance_overrides::BalanceOverrideRequest, + contracts::support::{AnyoneAuthenticator, Trader}, + model::order::{BuyTokenDestination, OrderKind, SellTokenSource}, + number::nonzero::NonZeroU256, + simulator::{ + encoding::WrapperCall, + swap_simulator::{Query, SwapSimulator, TradeEncoding}, + }, + std::time::Duration, + tokio::time::timeout, +}; + +const ESTIMATION_TIMEOUT: Duration = Duration::from_secs(3); + +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 { + timeout( + ESTIMATION_TIMEOUT, + self.estimate_inner(order, input, output), + ) + .await + .ok()? + } + + async fn estimate_inner( + &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()?; + + tracing::error!("encoded swap"); + + // 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, input, output) + .await?; + swap.overrides.extend(state_overrides); + + tracing::error!("added 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()?; + + tracing::error!("simulated tx"); + 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 + .map_err(|e| { + tracing::error!(?e, "tx gas estimation error"); + e + }) + .ok()?; + + tracing::error!(?gas, "Estimated tx gas"); + Some(eth::Gas(U256::from(gas))) + } + + async fn prepare_state_overrides( + &self, + solver: Address, + owner: Address, + input: eth::Asset, + 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: input.token.0, + holder: owner, + amount: input.amount, + }) + .await + { + overrides.insert(token, balance_override); + } + 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()) +}