From 5b02c742d151e3f668af3d5f4e0f8845b8b5c81b Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Tue, 21 Apr 2026 11:59:36 +0100 Subject: [PATCH 1/6] Make driver access list fetch optional and warn on deser errors --- .../src/domain/competition/pre_processing.rs | 6 +- .../domain/competition/solution/settlement.rs | 37 +++++++++-- crates/driver/src/infra/api/extract.rs | 65 +++++++++++++++++++ crates/driver/src/infra/api/mod.rs | 1 + .../driver/src/infra/api/routes/quote/mod.rs | 4 +- .../driver/src/infra/api/routes/reveal/mod.rs | 5 +- .../driver/src/infra/api/routes/settle/mod.rs | 5 +- 7 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 crates/driver/src/infra/api/extract.rs diff --git a/crates/driver/src/domain/competition/pre_processing.rs b/crates/driver/src/domain/competition/pre_processing.rs index 44f3effee5..f45971a74e 100644 --- a/crates/driver/src/domain/competition/pre_processing.rs +++ b/crates/driver/src/domain/competition/pre_processing.rs @@ -235,7 +235,11 @@ impl Utilities { observe::metrics::metrics().on_auction_overhead_start("driver", "parse_dto"); // deserialization takes tens of milliseconds so run it on a blocking task tokio::task::spawn_blocking(move || { - serde_json::from_slice(&solve_request).context("could not parse solve request") + serde_json::from_slice(&solve_request) + .inspect_err(|err| { + tracing::warn!(?err, "failed to parse /solve request body"); + }) + .context("could not parse solve request") }) .await .context("failed to await blocking task")?? diff --git a/crates/driver/src/domain/competition/solution/settlement.rs b/crates/driver/src/domain/competition/solution/settlement.rs index 907713046b..1611642dfc 100644 --- a/crates/driver/src/domain/competition/solution/settlement.rs +++ b/crates/driver/src/domain/competition/solution/settlement.rs @@ -148,9 +148,15 @@ impl Settlement { // The solution is to do access list estimation in two steps: first, simulate // moving 1 wei into every smart contract to get a partial access list, and then // use that partial access list to calculate the final access list. - let partial_access_lists = try_join_all(solution.user_trades().map(|trade| async { + // + // The same predicate also tells us whether an access list is strictly + // required for this settlement: the gas-limit workaround only matters + // when at least one trade sends ETH to a smart contract. For any other + // settlement the access list is purely a gas optimization, so a fetch + // failure should not abort submission. + let per_trade_access_lists = try_join_all(solution.user_trades().map(|trade| async { if !trade.order().buys_eth() || !trade.order().pays_to_contract(eth).await? { - return Ok(Default::default()); + return Result::<_, Error>::Ok((false, eth::AccessList::default())); } let tx = eth::Tx { from: solution.solver().address(), @@ -159,17 +165,20 @@ impl Settlement { input: Default::default(), access_list: Default::default(), }; - Result::<_, Error>::Ok(simulator.access_list(&tx).await?) + Ok((true, simulator.access_list(&tx).await?)) })) .await?; - let partial_access_list = partial_access_lists + let access_list_required = per_trade_access_lists.iter().any(|(r, _)| *r); + let partial_access_list = per_trade_access_lists .into_iter() + .map(|(_, list)| list) .fold(eth::AccessList::default(), |acc, list| acc.merge(list)); // Simulate the settlement and get the access list and gas. let (access_list, gas) = Self::simulate( transaction.internalized.clone(), &partial_access_list, + access_list_required, eth, simulator, ) @@ -203,6 +212,7 @@ impl Settlement { Self::simulate( transaction.uninternalized.clone(), &partial_access_list, + access_list_required, eth, simulator, ) @@ -224,6 +234,7 @@ impl Settlement { async fn simulate( tx: eth::Tx, partial_access_list: ð::AccessList, + access_list_required: bool, eth: &Ethereum, simulator: &Simulator, ) -> Result<(eth::AccessList, eth::Gas), Error> { @@ -231,8 +242,22 @@ impl Settlement { let tx = tx.set_access_list(partial_access_list.to_owned()); // Simulate the full access list, passing the partial access - // list into the simulation. - let access_list = simulator.access_list(&tx).await?; + // list into the simulation. When no trade strictly requires an access + // list (no ETH -> contract transfer), treat the fetch as best-effort: + // a failing RPC would otherwise abort the whole settlement even though + // the on-chain tx would succeed without it. + let access_list = match simulator.access_list(&tx).await { + Ok(list) => list, + Err(err) if !access_list_required => { + tracing::warn!( + ?err, + "access list estimation failed; continuing without it (no ETH->contract \ + trades)" + ); + partial_access_list.clone() + } + Err(err) => return Err(err.into()), + }; let tx = tx.set_access_list(access_list.clone()); // Simulate the settlement using the full access list and get the gas used. diff --git a/crates/driver/src/infra/api/extract.rs b/crates/driver/src/infra/api/extract.rs new file mode 100644 index 0000000000..5d14280907 --- /dev/null +++ b/crates/driver/src/infra/api/extract.rs @@ -0,0 +1,65 @@ +//! Axum extractors that emit a `warn` log when request deserialization +//! fails, then delegate to the stock extractor's rejection so the HTTP +//! response shape is unchanged. + +use { + axum::{ + extract::{ + FromRequest, + FromRequestParts, + Request, + rejection::{JsonRejection, QueryRejection}, + }, + http::request::Parts, + }, + serde::de::DeserializeOwned, +}; + +pub struct Json(pub T); + +impl FromRequest for Json +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = JsonRejection; + + async fn from_request(req: Request, state: &S) -> Result { + match axum::Json::::from_request(req, state).await { + Ok(axum::Json(value)) => Ok(Self(value)), + Err(rejection) => { + tracing::warn!( + err = %rejection, + target = std::any::type_name::(), + "failed to deserialize JSON request body", + ); + Err(rejection) + } + } + } +} + +pub struct Query(pub T); + +impl FromRequestParts for Query +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = QueryRejection; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + match axum::extract::Query::::from_request_parts(parts, state).await { + Ok(axum::extract::Query(value)) => Ok(Self(value)), + Err(rejection) => { + tracing::warn!( + err = %rejection, + target = std::any::type_name::(), + query = parts.uri.query().unwrap_or_default(), + "failed to deserialize query string", + ); + Err(rejection) + } + } + } +} diff --git a/crates/driver/src/infra/api/mod.rs b/crates/driver/src/infra/api/mod.rs index e9d0ce022b..0ba4ad0441 100644 --- a/crates/driver/src/infra/api/mod.rs +++ b/crates/driver/src/infra/api/mod.rs @@ -28,6 +28,7 @@ use { }; mod error; +mod extract; pub mod routes; pub struct Api { diff --git a/crates/driver/src/infra/api/routes/quote/mod.rs b/crates/driver/src/infra/api/routes/quote/mod.rs index 054cbc8819..b68723c897 100644 --- a/crates/driver/src/infra/api/routes/quote/mod.rs +++ b/crates/driver/src/infra/api/routes/quote/mod.rs @@ -1,6 +1,6 @@ use { crate::infra::{ - api::{Error, State}, + api::{Error, State, extract}, observe, }, tracing::Instrument, @@ -16,7 +16,7 @@ pub(in crate::infra::api) fn quote(router: axum::Router) -> axum::Router< async fn route( state: axum::extract::State, - order: axum::extract::Query, + order: extract::Query, ) -> Result, (axum::http::StatusCode, axum::Json)> { let handle_request = async { let order = order.0.into_domain(); diff --git a/crates/driver/src/infra/api/routes/reveal/mod.rs b/crates/driver/src/infra/api/routes/reveal/mod.rs index 08e388d761..fdaa4a5599 100644 --- a/crates/driver/src/infra/api/routes/reveal/mod.rs +++ b/crates/driver/src/infra/api/routes/reveal/mod.rs @@ -4,7 +4,7 @@ use { crate::{ domain::competition::auction, infra::{ - api::{self, Error, State}, + api::{self, Error, State, extract}, observe, }, }, @@ -17,8 +17,9 @@ pub(in crate::infra::api) fn reveal(router: axum::Router) -> axum::Router async fn route( state: axum::extract::State, - req: axum::Json, + req: extract::Json, ) -> Result, (axum::http::StatusCode, axum::Json)> { + let req = req.0; let auction_id = auction::Id::try_from(req.auction_id).map_err(api::routes::AuctionError::from)?; let handle_request = async { diff --git a/crates/driver/src/infra/api/routes/settle/mod.rs b/crates/driver/src/infra/api/routes/settle/mod.rs index 047b062e01..c9b8610484 100644 --- a/crates/driver/src/infra/api/routes/settle/mod.rs +++ b/crates/driver/src/infra/api/routes/settle/mod.rs @@ -4,7 +4,7 @@ use { crate::{ domain::competition::auction, infra::{ - api::{self, Error, State}, + api::{self, Error, State, extract}, observe, }, }, @@ -17,8 +17,9 @@ pub(in crate::infra::api) fn settle(router: axum::Router) -> axum::Router async fn route( state: axum::extract::State, - req: axum::Json, + req: extract::Json, ) -> Result<(), (axum::http::StatusCode, axum::Json)> { + let req = req.0; let auction_id = auction::Id::try_from(req.auction_id).map_err(api::routes::AuctionError::from)?; let solver = state.solver().name().to_string(); From 4d965e295ebc656b4aa187211cc8daf241f70741 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Wed, 22 Apr 2026 08:24:52 +0100 Subject: [PATCH 2/6] Address review feedback --- .../domain/competition/solution/settlement.rs | 78 +++++++++++-------- crates/driver/src/infra/api/extract.rs | 8 +- .../driver/src/infra/api/routes/quote/mod.rs | 6 +- .../driver/src/infra/api/routes/reveal/mod.rs | 5 +- .../driver/src/infra/api/routes/settle/mod.rs | 5 +- 5 files changed, 56 insertions(+), 46 deletions(-) diff --git a/crates/driver/src/domain/competition/solution/settlement.rs b/crates/driver/src/domain/competition/solution/settlement.rs index 1611642dfc..7945d537a4 100644 --- a/crates/driver/src/domain/competition/solution/settlement.rs +++ b/crates/driver/src/domain/competition/solution/settlement.rs @@ -1,5 +1,10 @@ use { - super::{Error, Solution, encoding, trade::ClearingPrices}, + super::{ + Error, + Solution, + encoding, + trade::{self, ClearingPrices}, + }, crate::{ domain::{ self, @@ -149,36 +154,23 @@ impl Settlement { // moving 1 wei into every smart contract to get a partial access list, and then // use that partial access list to calculate the final access list. // - // The same predicate also tells us whether an access list is strictly - // required for this settlement: the gas-limit workaround only matters - // when at least one trade sends ETH to a smart contract. For any other - // settlement the access list is purely a gas optimization, so a fetch - // failure should not abort submission. - let per_trade_access_lists = try_join_all(solution.user_trades().map(|trade| async { - if !trade.order().buys_eth() || !trade.order().pays_to_contract(eth).await? { - return Result::<_, Error>::Ok((false, eth::AccessList::default())); - } - let tx = eth::Tx { - from: solution.solver().address(), - to: trade.order().receiver(), - value: 1.into(), - input: Default::default(), - access_list: Default::default(), - }; - Ok((true, simulator.access_list(&tx).await?)) - })) - .await?; - let access_list_required = per_trade_access_lists.iter().any(|(r, _)| *r); - let partial_access_list = per_trade_access_lists - .into_iter() - .map(|(_, list)| list) - .fold(eth::AccessList::default(), |acc, list| acc.merge(list)); + // A non-empty partial access list also signals that at least one trade + // strictly requires an access list. Conversely, an empty partial list + // means the access list is only a gas optimization for this settlement, + // so a fetch failure below can be tolerated. + let partial_access_list = try_join_all( + solution + .user_trades() + .map(|trade| partial_access_list_for(trade, &solution, eth, simulator)), + ) + .await? + .into_iter() + .fold(eth::AccessList::default(), |acc, list| acc.merge(list)); // Simulate the settlement and get the access list and gas. let (access_list, gas) = Self::simulate( transaction.internalized.clone(), &partial_access_list, - access_list_required, eth, simulator, ) @@ -212,7 +204,6 @@ impl Settlement { Self::simulate( transaction.uninternalized.clone(), &partial_access_list, - access_list_required, eth, simulator, ) @@ -234,7 +225,6 @@ impl Settlement { async fn simulate( tx: eth::Tx, partial_access_list: ð::AccessList, - access_list_required: bool, eth: &Ethereum, simulator: &Simulator, ) -> Result<(eth::AccessList, eth::Gas), Error> { @@ -242,13 +232,13 @@ impl Settlement { let tx = tx.set_access_list(partial_access_list.to_owned()); // Simulate the full access list, passing the partial access - // list into the simulation. When no trade strictly requires an access - // list (no ETH -> contract transfer), treat the fetch as best-effort: - // a failing RPC would otherwise abort the whole settlement even though - // the on-chain tx would succeed without it. + // list into the simulation. If the partial list is empty, no trade + // strictly requires the access-list workaround, so treat the fetch as + // best-effort: a failing RPC would otherwise abort the whole settlement + // even though the on-chain tx would succeed without it. let access_list = match simulator.access_list(&tx).await { Ok(list) => list, - Err(err) if !access_list_required => { + Err(err) if partial_access_list.is_empty() => { tracing::warn!( ?err, "access list estimation failed; continuing without it (no ETH->contract \ @@ -378,6 +368,28 @@ impl Settlement { } } +/// Return the partial access list entries needed to forward 1 wei of ETH to a +/// smart-contract receiver. Returns an empty access list when the trade does +/// not trigger the gas-limit workaround (non-ETH buy or EOA receiver). +async fn partial_access_list_for( + trade: &trade::Fulfillment, + solution: &Solution, + eth: &Ethereum, + simulator: &Simulator, +) -> Result { + if !trade.order().buys_eth() || !trade.order().pays_to_contract(eth).await? { + return Ok(eth::AccessList::default()); + } + let tx = eth::Tx { + from: solution.solver().address(), + to: trade.order().receiver(), + value: 1.into(), + input: Default::default(), + access_list: Default::default(), + }; + Ok(simulator.access_list(&tx).await?) +} + /// Should the interactions be internalized? #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Internalization { diff --git a/crates/driver/src/infra/api/extract.rs b/crates/driver/src/infra/api/extract.rs index 5d14280907..97f79cb2f6 100644 --- a/crates/driver/src/infra/api/extract.rs +++ b/crates/driver/src/infra/api/extract.rs @@ -15,9 +15,9 @@ use { serde::de::DeserializeOwned, }; -pub struct Json(pub T); +pub struct LoggingJson(pub T); -impl FromRequest for Json +impl FromRequest for LoggingJson where S: Send + Sync, T: DeserializeOwned, @@ -39,9 +39,9 @@ where } } -pub struct Query(pub T); +pub struct LoggingQuery(pub T); -impl FromRequestParts for Query +impl FromRequestParts for LoggingQuery where S: Send + Sync, T: DeserializeOwned, diff --git a/crates/driver/src/infra/api/routes/quote/mod.rs b/crates/driver/src/infra/api/routes/quote/mod.rs index b68723c897..5fb791b2ac 100644 --- a/crates/driver/src/infra/api/routes/quote/mod.rs +++ b/crates/driver/src/infra/api/routes/quote/mod.rs @@ -1,6 +1,6 @@ use { crate::infra::{ - api::{Error, State, extract}, + api::{Error, State, extract::LoggingQuery}, observe, }, tracing::Instrument, @@ -16,10 +16,10 @@ pub(in crate::infra::api) fn quote(router: axum::Router) -> axum::Router< async fn route( state: axum::extract::State, - order: extract::Query, + LoggingQuery(order): LoggingQuery, ) -> Result, (axum::http::StatusCode, axum::Json)> { let handle_request = async { - let order = order.0.into_domain(); + let order = order.into_domain(); observe::quoting(&order); let quote = order .quote( diff --git a/crates/driver/src/infra/api/routes/reveal/mod.rs b/crates/driver/src/infra/api/routes/reveal/mod.rs index fdaa4a5599..0475649f20 100644 --- a/crates/driver/src/infra/api/routes/reveal/mod.rs +++ b/crates/driver/src/infra/api/routes/reveal/mod.rs @@ -4,7 +4,7 @@ use { crate::{ domain::competition::auction, infra::{ - api::{self, Error, State, extract}, + api::{self, Error, State, extract::LoggingJson}, observe, }, }, @@ -17,9 +17,8 @@ pub(in crate::infra::api) fn reveal(router: axum::Router) -> axum::Router async fn route( state: axum::extract::State, - req: extract::Json, + LoggingJson(req): LoggingJson, ) -> Result, (axum::http::StatusCode, axum::Json)> { - let req = req.0; let auction_id = auction::Id::try_from(req.auction_id).map_err(api::routes::AuctionError::from)?; let handle_request = async { diff --git a/crates/driver/src/infra/api/routes/settle/mod.rs b/crates/driver/src/infra/api/routes/settle/mod.rs index c9b8610484..3c7bec8a54 100644 --- a/crates/driver/src/infra/api/routes/settle/mod.rs +++ b/crates/driver/src/infra/api/routes/settle/mod.rs @@ -4,7 +4,7 @@ use { crate::{ domain::competition::auction, infra::{ - api::{self, Error, State, extract}, + api::{self, Error, State, extract::LoggingJson}, observe, }, }, @@ -17,9 +17,8 @@ pub(in crate::infra::api) fn settle(router: axum::Router) -> axum::Router async fn route( state: axum::extract::State, - req: extract::Json, + LoggingJson(req): LoggingJson, ) -> Result<(), (axum::http::StatusCode, axum::Json)> { - let req = req.0; let auction_id = auction::Id::try_from(req.auction_id).map_err(api::routes::AuctionError::from)?; let solver = state.solver().name().to_string(); From fefcbde86f519d0b86ee3ad14c4b7e8bc7357617 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Wed, 22 Apr 2026 11:36:41 +0100 Subject: [PATCH 3/6] Only suppress non-revert errors in access list fallback A revert from simulator.access_list signals a real problem with the settlement even when no trade strictly requires the access-list workaround, so it must still propagate. The empty-partial-list fallback now fires only for simulator::Error::Other (transport, deserialization, etc.) where we cannot reason about the simulation outcome. --- .../src/domain/competition/solution/settlement.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/driver/src/domain/competition/solution/settlement.rs b/crates/driver/src/domain/competition/solution/settlement.rs index 7945d537a4..2e1d62f461 100644 --- a/crates/driver/src/domain/competition/solution/settlement.rs +++ b/crates/driver/src/domain/competition/solution/settlement.rs @@ -20,7 +20,7 @@ use { alloy::primitives::U256, eth_domain_types as eth, futures::future::try_join_all, - simulator::Simulator, + simulator::{self, Simulator}, std::collections::{BTreeSet, HashMap, HashSet}, tracing::instrument, }; @@ -233,12 +233,15 @@ impl Settlement { // Simulate the full access list, passing the partial access // list into the simulation. If the partial list is empty, no trade - // strictly requires the access-list workaround, so treat the fetch as - // best-effort: a failing RPC would otherwise abort the whole settlement - // even though the on-chain tx would succeed without it. + // strictly requires the access-list workaround, so a non-revert + // failure (transport, deserialization, ...) shouldn't abort the + // settlement: we can't reason about the outcome, but the on-chain + // tx would still succeed without the access list. Revert errors are + // propagated either way since they signal a real problem with the + // settlement. let access_list = match simulator.access_list(&tx).await { Ok(list) => list, - Err(err) if partial_access_list.is_empty() => { + Err(simulator::Error::Other(err)) if partial_access_list.is_empty() => { tracing::warn!( ?err, "access list estimation failed; continuing without it (no ETH->contract \ From 788c2296ef0232d0966e81165792a90bbe28bd88 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Wed, 22 Apr 2026 11:39:57 +0100 Subject: [PATCH 4/6] Split request deserialization logging into separate PR Revert the LoggingJson / LoggingQuery extractors and the /solve body parse warning. They land in a follow-up PR so this branch only covers the access-list-fetch optionality change. --- .../src/domain/competition/pre_processing.rs | 6 +- crates/driver/src/infra/api/extract.rs | 65 ------------------- crates/driver/src/infra/api/mod.rs | 1 - .../driver/src/infra/api/routes/quote/mod.rs | 6 +- .../driver/src/infra/api/routes/reveal/mod.rs | 4 +- .../driver/src/infra/api/routes/settle/mod.rs | 4 +- 6 files changed, 8 insertions(+), 78 deletions(-) delete mode 100644 crates/driver/src/infra/api/extract.rs diff --git a/crates/driver/src/domain/competition/pre_processing.rs b/crates/driver/src/domain/competition/pre_processing.rs index f45971a74e..44f3effee5 100644 --- a/crates/driver/src/domain/competition/pre_processing.rs +++ b/crates/driver/src/domain/competition/pre_processing.rs @@ -235,11 +235,7 @@ impl Utilities { observe::metrics::metrics().on_auction_overhead_start("driver", "parse_dto"); // deserialization takes tens of milliseconds so run it on a blocking task tokio::task::spawn_blocking(move || { - serde_json::from_slice(&solve_request) - .inspect_err(|err| { - tracing::warn!(?err, "failed to parse /solve request body"); - }) - .context("could not parse solve request") + serde_json::from_slice(&solve_request).context("could not parse solve request") }) .await .context("failed to await blocking task")?? diff --git a/crates/driver/src/infra/api/extract.rs b/crates/driver/src/infra/api/extract.rs deleted file mode 100644 index 97f79cb2f6..0000000000 --- a/crates/driver/src/infra/api/extract.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Axum extractors that emit a `warn` log when request deserialization -//! fails, then delegate to the stock extractor's rejection so the HTTP -//! response shape is unchanged. - -use { - axum::{ - extract::{ - FromRequest, - FromRequestParts, - Request, - rejection::{JsonRejection, QueryRejection}, - }, - http::request::Parts, - }, - serde::de::DeserializeOwned, -}; - -pub struct LoggingJson(pub T); - -impl FromRequest for LoggingJson -where - S: Send + Sync, - T: DeserializeOwned, -{ - type Rejection = JsonRejection; - - async fn from_request(req: Request, state: &S) -> Result { - match axum::Json::::from_request(req, state).await { - Ok(axum::Json(value)) => Ok(Self(value)), - Err(rejection) => { - tracing::warn!( - err = %rejection, - target = std::any::type_name::(), - "failed to deserialize JSON request body", - ); - Err(rejection) - } - } - } -} - -pub struct LoggingQuery(pub T); - -impl FromRequestParts for LoggingQuery -where - S: Send + Sync, - T: DeserializeOwned, -{ - type Rejection = QueryRejection; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - match axum::extract::Query::::from_request_parts(parts, state).await { - Ok(axum::extract::Query(value)) => Ok(Self(value)), - Err(rejection) => { - tracing::warn!( - err = %rejection, - target = std::any::type_name::(), - query = parts.uri.query().unwrap_or_default(), - "failed to deserialize query string", - ); - Err(rejection) - } - } - } -} diff --git a/crates/driver/src/infra/api/mod.rs b/crates/driver/src/infra/api/mod.rs index 0ba4ad0441..e9d0ce022b 100644 --- a/crates/driver/src/infra/api/mod.rs +++ b/crates/driver/src/infra/api/mod.rs @@ -28,7 +28,6 @@ use { }; mod error; -mod extract; pub mod routes; pub struct Api { diff --git a/crates/driver/src/infra/api/routes/quote/mod.rs b/crates/driver/src/infra/api/routes/quote/mod.rs index 5fb791b2ac..054cbc8819 100644 --- a/crates/driver/src/infra/api/routes/quote/mod.rs +++ b/crates/driver/src/infra/api/routes/quote/mod.rs @@ -1,6 +1,6 @@ use { crate::infra::{ - api::{Error, State, extract::LoggingQuery}, + api::{Error, State}, observe, }, tracing::Instrument, @@ -16,10 +16,10 @@ pub(in crate::infra::api) fn quote(router: axum::Router) -> axum::Router< async fn route( state: axum::extract::State, - LoggingQuery(order): LoggingQuery, + order: axum::extract::Query, ) -> Result, (axum::http::StatusCode, axum::Json)> { let handle_request = async { - let order = order.into_domain(); + let order = order.0.into_domain(); observe::quoting(&order); let quote = order .quote( diff --git a/crates/driver/src/infra/api/routes/reveal/mod.rs b/crates/driver/src/infra/api/routes/reveal/mod.rs index 0475649f20..08e388d761 100644 --- a/crates/driver/src/infra/api/routes/reveal/mod.rs +++ b/crates/driver/src/infra/api/routes/reveal/mod.rs @@ -4,7 +4,7 @@ use { crate::{ domain::competition::auction, infra::{ - api::{self, Error, State, extract::LoggingJson}, + api::{self, Error, State}, observe, }, }, @@ -17,7 +17,7 @@ pub(in crate::infra::api) fn reveal(router: axum::Router) -> axum::Router async fn route( state: axum::extract::State, - LoggingJson(req): LoggingJson, + req: axum::Json, ) -> Result, (axum::http::StatusCode, axum::Json)> { let auction_id = auction::Id::try_from(req.auction_id).map_err(api::routes::AuctionError::from)?; diff --git a/crates/driver/src/infra/api/routes/settle/mod.rs b/crates/driver/src/infra/api/routes/settle/mod.rs index 3c7bec8a54..047b062e01 100644 --- a/crates/driver/src/infra/api/routes/settle/mod.rs +++ b/crates/driver/src/infra/api/routes/settle/mod.rs @@ -4,7 +4,7 @@ use { crate::{ domain::competition::auction, infra::{ - api::{self, Error, State, extract::LoggingJson}, + api::{self, Error, State}, observe, }, }, @@ -17,7 +17,7 @@ pub(in crate::infra::api) fn settle(router: axum::Router) -> axum::Router async fn route( state: axum::extract::State, - LoggingJson(req): LoggingJson, + req: axum::Json, ) -> Result<(), (axum::http::StatusCode, axum::Json)> { let auction_id = auction::Id::try_from(req.auction_id).map_err(api::routes::AuctionError::from)?; From 5fa6473490a7f7a8b3c34acb690aede671ea274c Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Wed, 22 Apr 2026 16:10:47 +0100 Subject: [PATCH 5/6] Introduce RequiredAccessList newtype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes "trade needs the ETH->contract gas-limit workaround" at the type level. partial_access_list_for returns Option (None = not required), the aggregation reduces only the required entries, and the simulate() fallback keys off is_none() instead of is_empty() — unambiguous regardless of whether a required entry set happens to be empty. --- .../domain/competition/solution/settlement.rs | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/crates/driver/src/domain/competition/solution/settlement.rs b/crates/driver/src/domain/competition/solution/settlement.rs index 2e1d62f461..bec6b47320 100644 --- a/crates/driver/src/domain/competition/solution/settlement.rs +++ b/crates/driver/src/domain/competition/solution/settlement.rs @@ -154,23 +154,25 @@ impl Settlement { // moving 1 wei into every smart contract to get a partial access list, and then // use that partial access list to calculate the final access list. // - // A non-empty partial access list also signals that at least one trade - // strictly requires an access list. Conversely, an empty partial list - // means the access list is only a gas optimization for this settlement, - // so a fetch failure below can be tolerated. - let partial_access_list = try_join_all( + // `Some(..)` means at least one trade strictly requires an access list; + // `None` means it is purely a gas optimization for this settlement, so a + // non-revert fetch failure below can be tolerated. + let partial_access_list: Option = try_join_all( solution .user_trades() .map(|trade| partial_access_list_for(trade, &solution, eth, simulator)), ) .await? .into_iter() - .fold(eth::AccessList::default(), |acc, list| acc.merge(list)); + .flatten() + .map(|required| required.0) + .reduce(|acc, list| acc.merge(list)) + .map(RequiredAccessList); // Simulate the settlement and get the access list and gas. let (access_list, gas) = Self::simulate( transaction.internalized.clone(), - &partial_access_list, + partial_access_list.as_ref(), eth, simulator, ) @@ -203,7 +205,7 @@ impl Settlement { // that the settlement simulates even when internalizations are disabled. Self::simulate( transaction.uninternalized.clone(), - &partial_access_list, + partial_access_list.as_ref(), eth, simulator, ) @@ -224,30 +226,33 @@ impl Settlement { #[instrument(name = "simulate_settlement", skip_all)] async fn simulate( tx: eth::Tx, - partial_access_list: ð::AccessList, + partial_access_list: Option<&RequiredAccessList>, eth: &Ethereum, simulator: &Simulator, ) -> Result<(eth::AccessList, eth::Gas), Error> { // Add the partial access list to the settlement tx. - let tx = tx.set_access_list(partial_access_list.to_owned()); + let tx = tx.set_access_list( + partial_access_list + .map(|required| required.0.clone()) + .unwrap_or_default(), + ); // Simulate the full access list, passing the partial access - // list into the simulation. If the partial list is empty, no trade - // strictly requires the access-list workaround, so a non-revert - // failure (transport, deserialization, ...) shouldn't abort the - // settlement: we can't reason about the outcome, but the on-chain - // tx would still succeed without the access list. Revert errors are - // propagated either way since they signal a real problem with the - // settlement. + // list into the simulation. When no trade strictly requires the + // access-list workaround, a non-revert failure (transport, + // deserialization, ...) shouldn't abort the settlement: we can't + // reason about the outcome, but the on-chain tx would still succeed + // without the access list. Revert errors are propagated either way + // since they signal a real problem with the settlement. let access_list = match simulator.access_list(&tx).await { Ok(list) => list, - Err(simulator::Error::Other(err)) if partial_access_list.is_empty() => { + Err(simulator::Error::Other(err)) if partial_access_list.is_none() => { tracing::warn!( ?err, "access list estimation failed; continuing without it (no ETH->contract \ trades)" ); - partial_access_list.clone() + eth::AccessList::default() } Err(err) => return Err(err.into()), }; @@ -371,17 +376,24 @@ impl Settlement { } } -/// Return the partial access list entries needed to forward 1 wei of ETH to a -/// smart-contract receiver. Returns an empty access list when the trade does -/// not trigger the gas-limit workaround (non-ETH buy or EOA receiver). +/// Access list entries covering a trade that strictly requires the +/// ETH→contract gas-limit workaround. Distinct from a bare `eth::AccessList` +/// so the settlement-level fallback can tell "no trade required one" (`None`) +/// apart from "trade required one but the list turned out empty" (`Some`). +#[derive(Debug, Clone)] +struct RequiredAccessList(eth::AccessList); + +/// Return the partial access list for a single trade. `None` means the trade +/// does not trigger the gas-limit workaround (non-ETH buy or EOA receiver), +/// so its absence must not be treated as "required but empty". async fn partial_access_list_for( trade: &trade::Fulfillment, solution: &Solution, eth: &Ethereum, simulator: &Simulator, -) -> Result { +) -> Result, Error> { if !trade.order().buys_eth() || !trade.order().pays_to_contract(eth).await? { - return Ok(eth::AccessList::default()); + return Ok(None); } let tx = eth::Tx { from: solution.solver().address(), @@ -390,7 +402,7 @@ async fn partial_access_list_for( input: Default::default(), access_list: Default::default(), }; - Ok(simulator.access_list(&tx).await?) + Ok(Some(RequiredAccessList(simulator.access_list(&tx).await?))) } /// Should the interactions be internalized? From da0c520963d6a3e69a74d6ff8a1a807b595694f8 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Mon, 27 Apr 2026 13:07:15 +0100 Subject: [PATCH 6/6] Nits --- .../src/domain/competition/solution/settlement.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/driver/src/domain/competition/solution/settlement.rs b/crates/driver/src/domain/competition/solution/settlement.rs index bec6b47320..c2d62f41fa 100644 --- a/crates/driver/src/domain/competition/solution/settlement.rs +++ b/crates/driver/src/domain/competition/solution/settlement.rs @@ -249,8 +249,7 @@ impl Settlement { Err(simulator::Error::Other(err)) if partial_access_list.is_none() => { tracing::warn!( ?err, - "access list estimation failed; continuing without it (no ETH->contract \ - trades)" + "access list estimation failed, falling back to empty list" ); eth::AccessList::default() } @@ -376,16 +375,13 @@ impl Settlement { } } -/// Access list entries covering a trade that strictly requires the -/// ETH→contract gas-limit workaround. Distinct from a bare `eth::AccessList` -/// so the settlement-level fallback can tell "no trade required one" (`None`) -/// apart from "trade required one but the list turned out empty" (`Some`). +/// Access lists that are required when the order buys native ETH and the +/// receiver is a smart-contract. #[derive(Debug, Clone)] struct RequiredAccessList(eth::AccessList); -/// Return the partial access list for a single trade. `None` means the trade -/// does not trigger the gas-limit workaround (non-ETH buy or EOA receiver), -/// so its absence must not be treated as "required but empty". +/// Returns the partial access list for a single trade, or `None` if the +/// trade does not buy native ETH or its receiver has no on-chain code. async fn partial_access_list_for( trade: &trade::Fulfillment, solution: &Solution,