From 30f18fe90c629478fe6606940c0d058ecf17ae99 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 13 Aug 2026 13:07:50 +0000 Subject: [PATCH 1/5] minor fixes --- crates/price-estimation/src/trade_finding/external.rs | 7 ++++++- crates/shared/src/event_storing_helpers.rs | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/price-estimation/src/trade_finding/external.rs b/crates/price-estimation/src/trade_finding/external.rs index 15ff036d03..07fc18af4e 100644 --- a/crates/price-estimation/src/trade_finding/external.rs +++ b/crates/price-estimation/src/trade_finding/external.rs @@ -402,7 +402,12 @@ pub mod dto { pub amount: U256, pub kind: OrderKind, pub deadline: chrono::DateTime, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] + // Renamed to match the driver's `enableFastPath` field name. + #[serde( + rename = "enableFastPath", + default, + skip_serializing_if = "std::ops::Not::not" + )] pub fast_path: bool, #[serde(skip_serializing_if = "Option::is_none")] pub auction_id: Option, diff --git a/crates/shared/src/event_storing_helpers.rs b/crates/shared/src/event_storing_helpers.rs index 55fbc5a5e6..43224a5099 100644 --- a/crates/shared/src/event_storing_helpers.rs +++ b/crates/shared/src/event_storing_helpers.rs @@ -29,7 +29,7 @@ pub fn create_quote_row(competition: &QuoteCompetition) -> Result { solver: ByteArray(*data.solver.0), verified: data.verified, metadata: data.metadata.try_into()?, - auction_id: None, + auction_id: data.auction_id, }) } From 77a9868ff20cf578c429f1745c613c633e68ac37 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 13 Aug 2026 06:53:23 +0000 Subject: [PATCH 2/5] pass around solution id --- crates/price-estimation/src/lib.rs | 3 +++ crates/price-estimation/src/native/mod.rs | 1 + crates/price-estimation/src/sanitized.rs | 18 +++++++++++++++ .../src/trade_finding/external.rs | 13 +++++++++++ .../price-estimation/src/trade_finding/mod.rs | 16 ++++++++++++++ .../src/trade_finding/trade_estimator.rs | 1 + crates/price-estimation/src/trade_verifier.rs | 2 ++ crates/shared/src/order_quoting.rs | 22 +++++++++++++++++++ 8 files changed, 76 insertions(+) diff --git a/crates/price-estimation/src/lib.rs b/crates/price-estimation/src/lib.rs index 38ecf0c10a..b95b920ad3 100644 --- a/crates/price-estimation/src/lib.rs +++ b/crates/price-estimation/src/lib.rs @@ -203,6 +203,9 @@ pub struct Estimate { /// Whether the quoting solver supports fast-path (out-of-competition) /// execution for this order. pub supports_fast_path: bool, + /// Solver-assigned solution id when the underlying quote response + /// carried one. Threaded end-to-end into `QuoteResponse.solution_id`. + pub solution_id: Option, /// Data associated with this estimation. #[debug(ignore)] pub execution: QuoteExecution, diff --git a/crates/price-estimation/src/native/mod.rs b/crates/price-estimation/src/native/mod.rs index 0e7bdcf07e..490496955f 100644 --- a/crates/price-estimation/src/native/mod.rs +++ b/crates/price-estimation/src/native/mod.rs @@ -157,6 +157,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }) } diff --git a/crates/price-estimation/src/sanitized.rs b/crates/price-estimation/src/sanitized.rs index 70242b6a4a..8c1d755289 100644 --- a/crates/price-estimation/src/sanitized.rs +++ b/crates/price-estimation/src/sanitized.rs @@ -126,6 +126,7 @@ impl SanitizedPriceEstimator { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }; tracing::debug!(?query, ?estimation, "generate trivial price estimation"); @@ -141,6 +142,7 @@ impl SanitizedPriceEstimator { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }; tracing::debug!(?query, ?estimation, "generate trivial unwrap estimation"); @@ -156,6 +158,7 @@ impl SanitizedPriceEstimator { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }; tracing::debug!(?query, ?estimation, "generate trivial wrap estimation"); @@ -224,6 +227,7 @@ mod tests { solver: Default::default(), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -250,6 +254,7 @@ mod tests { solver: Default::default(), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -293,6 +298,7 @@ mod tests { solver: Default::default(), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -316,6 +322,7 @@ mod tests { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -338,6 +345,7 @@ mod tests { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -361,6 +369,7 @@ mod tests { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -384,6 +393,7 @@ mod tests { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -456,6 +466,7 @@ mod tests { solver: Default::default(), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }) } @@ -473,6 +484,7 @@ mod tests { solver: Default::default(), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }) } @@ -490,6 +502,7 @@ mod tests { solver: Default::default(), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }) } @@ -507,6 +520,7 @@ mod tests { solver: Default::default(), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }) } @@ -553,6 +567,7 @@ mod tests { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -574,6 +589,7 @@ mod tests { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ), @@ -600,6 +616,7 @@ mod tests { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }) } @@ -617,6 +634,7 @@ mod tests { solver: Default::default(), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }) } diff --git a/crates/price-estimation/src/trade_finding/external.rs b/crates/price-estimation/src/trade_finding/external.rs index 07fc18af4e..898fe6f006 100644 --- a/crates/price-estimation/src/trade_finding/external.rs +++ b/crates/price-estimation/src/trade_finding/external.rs @@ -184,6 +184,7 @@ impl From for LegacyTrade { solver: quote.solver, tx_origin: quote.tx_origin, supports_fast_path: quote.supports_fast_path, + solution_id: quote.solution_id, } } } @@ -215,6 +216,7 @@ impl From for Trade { tx_origin: quote.tx_origin, jit_orders: quote.jit_orders, supports_fast_path: quote.supports_fast_path, + solution_id: quote.solution_id, } } } @@ -363,6 +365,7 @@ impl TradeFinding for ExternalTradeFinder { gas_estimate, solver: trade.solver(), supports_fast_path: trade.supports_fast_path(), + solution_id: trade.solution_id(), execution: QuoteExecution { interactions: map_interactions_data(trade.interactions()), pre_interactions: map_interactions_data(trade.pre_interactions()), @@ -434,6 +437,11 @@ pub mod dto { pub tx_origin: Option
, #[serde(default)] pub supports_fast_path: bool, + /// Solver-assigned solution id for this quote. Threaded through to + /// `QuoteResponse.solution_id` and, ultimately, into + /// `proposed_solutions.id` when a fast-path quote is persisted. + #[serde(default)] + pub solution_id: Option, } #[serde_as] @@ -453,6 +461,11 @@ pub mod dto { pub jit_orders: Vec, #[serde(default)] pub supports_fast_path: bool, + /// Solver-assigned solution id for this quote. Threaded through to + /// `QuoteResponse.solution_id` and, ultimately, into + /// `proposed_solutions.id` when a fast-path quote is persisted. + #[serde(default)] + pub solution_id: Option, } #[serde_as] diff --git a/crates/price-estimation/src/trade_finding/mod.rs b/crates/price-estimation/src/trade_finding/mod.rs index f71d43e761..8635da693b 100644 --- a/crates/price-estimation/src/trade_finding/mod.rs +++ b/crates/price-estimation/src/trade_finding/mod.rs @@ -37,6 +37,9 @@ pub struct Quote { pub solver: Address, /// Whether the quoting solver supports fast-path execution. pub supports_fast_path: bool, + /// Solver-assigned solution id, when the underlying quote response + /// carries one. + pub solution_id: Option, #[debug(ignore)] pub execution: QuoteExecution, } @@ -90,6 +93,13 @@ impl TradeKind { } } + pub fn solution_id(&self) -> Option { + match self { + TradeKind::Legacy(trade) => trade.solution_id, + TradeKind::Regular(trade) => trade.solution_id, + } + } + pub fn simulation_solver_address(&self) -> Address { self.tx_origin().unwrap_or(self.solver()) } @@ -148,6 +158,9 @@ pub struct LegacyTrade { pub tx_origin: Option
, /// Whether the quoting solver supports fast-path execution. pub supports_fast_path: bool, + /// Solver-assigned solution id for this trade, if the response carried + /// one. + pub solution_id: Option, } /// A trade with JIT orders. @@ -169,6 +182,9 @@ pub struct Trade { pub jit_orders: Vec, /// Whether the quoting solver supports fast-path execution. pub supports_fast_path: bool, + /// Solver-assigned solution id for this trade, if the response carried + /// one. + pub solution_id: Option, } impl Trade { diff --git a/crates/price-estimation/src/trade_finding/trade_estimator.rs b/crates/price-estimation/src/trade_finding/trade_estimator.rs index bee8cb9f2a..73a211e036 100644 --- a/crates/price-estimation/src/trade_finding/trade_estimator.rs +++ b/crates/price-estimation/src/trade_finding/trade_estimator.rs @@ -90,6 +90,7 @@ impl Inner { solver: quote.solver, verified: false, supports_fast_path: quote.supports_fast_path, + solution_id: quote.solution_id, execution: quote.execution, }) } diff --git a/crates/price-estimation/src/trade_verifier.rs b/crates/price-estimation/src/trade_verifier.rs index db993addfe..aeb69a1e02 100644 --- a/crates/price-estimation/src/trade_verifier.rs +++ b/crates/price-estimation/src/trade_verifier.rs @@ -130,6 +130,7 @@ impl TradeVerifier { solver: trade.solver(), verified: false, supports_fast_path: trade.supports_fast_path(), + solution_id: trade.solution_id(), execution: QuoteExecution { interactions: map_interactions_data(trade.interactions()), pre_interactions: map_interactions_data(trade.pre_interactions()), @@ -821,6 +822,7 @@ fn ensure_quote_accuracy( solver: trade.solver(), verified: true, supports_fast_path: trade.supports_fast_path(), + solution_id: trade.solution_id(), execution: QuoteExecution { interactions: map_interactions_data(trade.interactions()), pre_interactions: map_interactions_data(trade.pre_interactions()), diff --git a/crates/shared/src/order_quoting.rs b/crates/shared/src/order_quoting.rs index b4e190e60e..77fd737c61 100644 --- a/crates/shared/src/order_quoting.rs +++ b/crates/shared/src/order_quoting.rs @@ -261,6 +261,10 @@ pub struct QuoteResponse { pub verified: bool, pub supports_fast_path: bool, pub metadata: QuoteMetadata, + /// Solver-assigned solution id, when the underlying solver quote + /// response carries one. Populated into `proposed_solutions.id` when + /// the quote is persisted as fast-path competition data. + pub solution_id: Option, } impl QuoteCompetition { @@ -929,6 +933,7 @@ fn assemble_quote_data( jit_orders: estimate.execution.jit_orders, } .into(), + solution_id: estimate.solution_id, } }; @@ -1131,6 +1136,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }, [], @@ -1176,6 +1182,7 @@ mod tests { verified: false, supports_fast_path: false, metadata: Default::default(), + solution_id: None, }, [], QuoteCompetitionMetadata { @@ -1291,6 +1298,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }, [], @@ -1336,6 +1344,7 @@ mod tests { verified: false, supports_fast_path: false, metadata: Default::default(), + solution_id: None, }, [], QuoteCompetitionMetadata { @@ -1446,6 +1455,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }, [], @@ -1491,6 +1501,7 @@ mod tests { verified: false, supports_fast_path: false, metadata: Default::default(), + solution_id: None, }, [], QuoteCompetitionMetadata { @@ -1584,6 +1595,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }, [], @@ -1662,6 +1674,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }, [], @@ -2150,6 +2163,7 @@ mod tests { solver: Address::repeat_byte(7), verified: true, supports_fast_path: false, + solution_id: None, execution: Default::default(), }; @@ -2208,6 +2222,7 @@ mod tests { solver: Address::repeat_byte(7), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }; @@ -2313,6 +2328,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), Ok(price_estimation::Estimate { @@ -2321,6 +2337,7 @@ mod tests { solver: Address::repeat_byte(2), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ]) @@ -2369,6 +2386,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), // zero gas - must be dropped silently @@ -2378,6 +2396,7 @@ mod tests { solver: Address::repeat_byte(2), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), // zero out_amount - must be dropped silently @@ -2387,6 +2406,7 @@ mod tests { solver: Address::repeat_byte(3), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), }), ]) @@ -2474,6 +2494,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), })]) .boxed() @@ -2522,6 +2543,7 @@ mod tests { solver: Address::repeat_byte(1), verified: false, supports_fast_path: false, + solution_id: None, execution: Default::default(), } } From c1b50939e8e3c4744954c99162f70803885f66af Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 13 Aug 2026 06:53:10 +0000 Subject: [PATCH 3/5] Store quote competition data --- crates/autopilot/src/database/auction.rs | 9 +- crates/autopilot/src/database/mod.rs | 16 +- .../src/database/onchain_order_events/mod.rs | 53 ++- crates/autopilot/src/run.rs | 7 +- crates/database/src/quotes.rs | 13 + crates/database/src/solver_competition_v2.rs | 116 +++++++ crates/orderbook/src/database/mod.rs | 13 +- crates/orderbook/src/database/orders.rs | 92 ++++-- crates/orderbook/src/database/quotes.rs | 11 +- crates/orderbook/src/orderbook.rs | 12 +- crates/orderbook/src/run.rs | 4 +- crates/shared/src/lib.rs | 1 + crates/shared/src/order_quoting.rs | 5 + crates/shared/src/quote_storage.rs | 301 ++++++++++++++++++ 14 files changed, 599 insertions(+), 54 deletions(-) create mode 100644 crates/shared/src/quote_storage.rs diff --git a/crates/autopilot/src/database/auction.rs b/crates/autopilot/src/database/auction.rs index 089acca29a..e422396147 100644 --- a/crates/autopilot/src/database/auction.rs +++ b/crates/autopilot/src/database/auction.rs @@ -9,8 +9,9 @@ use { num::ToPrimitive, shared::{ db_order_conversions::full_order_into_model_order, - event_storing_helpers::{create_db_search_parameters, create_quote_row}, + event_storing_helpers::create_db_search_parameters, order_quoting::{QuoteCompetition, QuoteData, QuoteSearchParameters, QuoteStoring}, + quote_storage::save_quote_competition, }, std::{collections::HashMap, ops::DerefMut, sync::Arc}, }; @@ -23,9 +24,9 @@ impl QuoteStoring for Postgres { .with_label_values(&["save_quote"]) .start_timer(); - let mut ex = self.pool.acquire().await?; - let row = create_quote_row(&data)?; - let id = database::quotes::save(&mut ex, &row).await?; + let mut tx = self.pool.begin().await?; + let id = save_quote_competition(&mut tx, data, &self.domain_separator).await?; + tx.commit().await?; Ok(id) } diff --git a/crates/autopilot/src/database/mod.rs b/crates/autopilot/src/database/mod.rs index 1b1ead85f5..9388d471c6 100644 --- a/crates/autopilot/src/database/mod.rs +++ b/crates/autopilot/src/database/mod.rs @@ -1,4 +1,5 @@ use { + model::DomainSeparator, num::ToPrimitive, shared::arguments::DB_MAX_CONNECTIONS_DEFAULT, sqlx::{Executor, PgConnection, PgPool, postgres::PgPoolOptions}, @@ -40,6 +41,10 @@ impl Default for Config { pub struct Postgres { pub pool: PgPool, pub config: Config, + /// Used to recover JIT order owners and compute their `order_uid` + /// before persisting fast-path quote competitions. Defaults to zero; + /// production callers must set it via `with_domain_separator`. + pub domain_separator: DomainSeparator, } impl Postgres { @@ -51,7 +56,16 @@ impl Postgres { Self::start_db_metrics_job(pool.clone()); - Ok(Self { pool, config }) + Ok(Self { + pool, + config, + domain_separator: DomainSeparator::default(), + }) + } + + pub fn with_domain_separator(mut self, domain_separator: DomainSeparator) -> Self { + self.domain_separator = domain_separator; + self } fn start_db_metrics_job(pool: PgPool) { diff --git a/crates/autopilot/src/database/onchain_order_events/mod.rs b/crates/autopilot/src/database/onchain_order_events/mod.rs index 4416fe23d3..244a490131 100644 --- a/crates/autopilot/src/database/onchain_order_events/mod.rs +++ b/crates/autopilot/src/database/onchain_order_events/mod.rs @@ -218,6 +218,7 @@ impl OnchainOrderParser { order_placement_events: Vec<(ContractEvent, Log)>, ) -> Result<( Vec, + Vec, Vec>, Vec<(database::events::EventIndex, OnchainOrderPlacement)>, Vec, @@ -265,7 +266,7 @@ impl OnchainOrderParser { .await; let data_tuple = onchain_order_data.into_iter().map( - |(event_index, quote, onchain_order_placement, order, tx_hash)| { + |(event_index, quote_id, quote, onchain_order_placement, order, tx_hash)| { ( self.custom_onchain_data_parser .customized_event_data_for_event_index( @@ -274,6 +275,7 @@ impl OnchainOrderParser { &custom_data_hashmap, &onchain_order_placement, ), + quote_id, quote, (event_index, onchain_order_placement), order, @@ -318,9 +320,9 @@ impl OnchainOrderParser { .collect(); let invalidation_events = get_invalidation_events(events)?; let invalided_order_uids = extract_invalidated_order_uids(invalidation_events)?; - let (custom_onchain_data, quotes, broadcasted_order_data, mut orders, tx_hashes) = self - .extract_custom_and_general_order_data(order_placement_events) - .await?; + let (custom_onchain_data, quote_ids, quotes, broadcasted_order_data, mut orders, tx_hashes) = + self.extract_custom_and_general_order_data(order_placement_events) + .await?; database::onchain_invalidations::insert_onchain_invalidations( transaction, @@ -364,6 +366,33 @@ impl OnchainOrderParser { .await .context("insert_orders failed")?; + // Promote fast-path quotes for onchain orders (mirrors the trait-based + // path in the orderbook). For each successfully-quoted order tied to a + // fast-path `auction_id`, drop the transient `quotes` row and rewrite + // the placeholder `proposed_trade_executions.order_uid` to the real + // one. + for (quote_id, quote, order) in izip!("e_ids, "es, &orders) { + let Some(quote) = quote else { + continue; + }; + let Some(auction_id) = quote.auction_id else { + continue; + }; + // The order_quotes row already carries the auction_id (populated + // inline above by `insert_quotes`), so all that's left is to drop + // the transient `quotes` row and patch competition tables. + database::quotes::delete_and_return_row(transaction, *quote_id) + .await + .context("failed to delete promoted onchain quote")?; + database::solver_competition_v2::finalize_quote_competition( + transaction, + auction_id, + order.uid, + ) + .await + .context("failed to patch competition rows for onchain order")?; + } + for order in &invalided_order_uids { tracing::debug!(?order, "invalidated order"); } @@ -442,6 +471,7 @@ fn extract_invalidated_order_uids( type GeneralOnchainOrderPlacementData = ( EventIndex, + i64, Option, OnchainOrderPlacement, Order, @@ -513,7 +543,14 @@ where None } }; - Ok((event_index, quote, order_data.0, order_data.1, tx_hash)) + Ok(( + event_index, + quote_id, + quote, + order_data.0, + order_data.1, + tx_hash, + )) }, ); let onchain_order_placement_data: Vec> = @@ -1269,6 +1306,7 @@ mod test { db: Postgres { pool: PgPool::connect_lazy("postgresql://").unwrap(), config: Default::default(), + domain_separator: Default::default(), }, trampoline: HooksTrampoline::Instance::deployed(&web3.provider) .await @@ -1318,9 +1356,10 @@ mod test { metadata: quote.data.metadata.clone().try_into().unwrap(), auction_id: quote.data.auction_id, }; - assert_eq!(result.1, vec![Some(expected_quote)]); + assert_eq!(result.1, vec![0i64]); + assert_eq!(result.2, vec![Some(expected_quote)]); assert_eq!( - result.2, + result.3, vec![( expected_event_index, OnchainOrderPlacement { diff --git a/crates/autopilot/src/run.rs b/crates/autopilot/src/run.rs index 33733215f1..601835e501 100644 --- a/crates/autopilot/src/run.rs +++ b/crates/autopilot/src/run.rs @@ -189,7 +189,7 @@ pub async fn start(args: impl Iterator) { /// Assumes tracing and metrics registry have already been set up. pub async fn run(config: Configuration, shutdown_controller: ShutdownController) { assert!(config.shadow.is_none(), "cannot run in shadow mode"); - let db_write = Postgres::new( + let mut db_write = Postgres::new( config.database.write_url.as_str(), crate::database::Config { insert_batch_size: config.database.insert_batch_size, @@ -257,6 +257,9 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController) .await .expect("Couldn't get vault relayer address"); + let domain_separator = DomainSeparator::new(chain_id, *eth.contracts().settlement().address()); + db_write.domain_separator = domain_separator; + let chain = Chain::try_from(chain_id).expect("incorrect chain ID"); let balance_overrider = config.price_estimation.balance_overrides.init(web3.clone()); @@ -575,7 +578,7 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController) web3.clone(), quoter.clone(), Box::new(custom_ethflow_order_parser), - DomainSeparator::new(chain_id, *eth.contracts().settlement().address()), + domain_separator, *eth.contracts().settlement().address(), eth.contracts().trampoline().clone(), ); diff --git a/crates/database/src/quotes.rs b/crates/database/src/quotes.rs index 509c163b3e..19e3af7b1e 100644 --- a/crates/database/src/quotes.rs +++ b/crates/database/src/quotes.rs @@ -94,6 +94,19 @@ WHERE id = $1 sqlx::query_as(QUERY).bind(id).fetch_optional(ex).await } +/// Deletes the row from the transient `quotes` table and returns it. +/// Used when a quote is promoted to an `order_quotes` row at order-placement +/// time — the caller reuses the returned row's fields to build the +/// `order_quotes` insert. +#[instrument(skip_all)] +pub async fn delete_and_return_row( + ex: &mut PgConnection, + id: QuoteId, +) -> Result, sqlx::Error> { + const QUERY: &str = "DELETE FROM quotes WHERE id = $1 RETURNING *"; + sqlx::query_as(QUERY).bind(id).fetch_optional(ex).await +} + /// Fields for searching stored quotes. #[derive(Clone)] pub struct QuoteSearchParameters { diff --git a/crates/database/src/solver_competition_v2.rs b/crates/database/src/solver_competition_v2.rs index 8712908485..ef847a56c1 100644 --- a/crates/database/src/solver_competition_v2.rs +++ b/crates/database/src/solver_competition_v2.rs @@ -355,6 +355,122 @@ async fn save_jit_orders( Ok(()) } +/// Because the final order uid is not known when we store the quote +/// competition data we use `0x000...000` as a sentinel value. +/// When an order gets placed referencing a quote competition this function +/// replaces the placeholder value with the now final order uid. +#[instrument(skip_all)] +pub async fn finalize_quote_competition( + ex: &mut PgTransaction<'_>, + auction_id: AuctionId, + order_uid: OrderUid, +) -> Result<(), sqlx::Error> { + const QUERY: &str = r#" +WITH patch_te AS ( + UPDATE proposed_trade_executions + SET order_uid = $1 + WHERE auction_id = $2 AND order_uid = $3 +) +UPDATE competition_auctions +SET order_uids = ARRAY[$1] +WHERE id = $2 +"#; + sqlx::query(QUERY) + .bind(order_uid) + .bind(auction_id) + .bind(OrderUid::default()) + .execute(ex.deref_mut()) + .await?; + Ok(()) +} + +/// Deletes all competition rows associated with `auction_id` across +/// `proposed_trade_executions`, `proposed_jit_orders`, `proposed_solutions`, +/// and `competition_auctions`. +#[instrument(skip_all)] +pub async fn delete_by_auction_id( + ex: &mut PgTransaction<'_>, + auction_id: AuctionId, +) -> Result<(), sqlx::Error> { + const QUERY: &str = r#" +WITH + del_te AS (DELETE FROM proposed_trade_executions WHERE auction_id = $1), + del_jo AS (DELETE FROM proposed_jit_orders WHERE auction_id = $1), + del_ps AS (DELETE FROM proposed_solutions WHERE auction_id = $1) +DELETE FROM competition_auctions WHERE id = $1 +"#; + sqlx::query(QUERY) + .bind(auction_id) + .execute(ex.deref_mut()) + .await?; + Ok(()) +} + +/// A JIT order proposed by a solver as part of a quote response. Distinct +/// from the autopilot flow, where a JIT order is identified at runtime by +/// its `order_uid` not appearing in the `orders` table. In the quoting +/// flow the placeholder user trade also has no matching `orders` row, so +/// we track JIT-ness explicitly instead. +#[derive(Clone, Debug, PartialEq)] +pub struct QuoteJitOrder { + pub solution_uid: i64, + pub order_uid: OrderUid, + pub sell_token: Address, + pub buy_token: Address, + pub limit_sell: BigDecimal, + pub limit_buy: BigDecimal, + pub side: OrderKind, +} + +/// Persists competition data derived from a fast-path quote response. +/// `solutions[i].orders` should carry every trade (placeholder user trade +/// plus each JIT trade) — these are written to `proposed_trade_executions`. +/// `jit_orders` carries the corresponding entries for `proposed_jit_orders` +/// and is inserted unconditionally. +#[instrument(skip_all)] +pub async fn save_from_quote( + ex: &mut PgTransaction<'_>, + auction_id: AuctionId, + solutions: &[Solution], + jit_orders: &[QuoteJitOrder], +) -> Result<(), sqlx::Error> { + if solutions.is_empty() { + return Ok(()); + } + save_solutions(ex, auction_id, solutions).await?; + save_trade_executions(ex, auction_id, solutions).await?; + save_jit_orders_unchecked(ex, auction_id, jit_orders).await?; + Ok(()) +} + +#[instrument(skip_all)] +async fn save_jit_orders_unchecked( + ex: &mut PgTransaction<'_>, + auction_id: AuctionId, + jits: &[QuoteJitOrder], +) -> Result<(), sqlx::Error> { + if jits.is_empty() { + return Ok(()); + } + let mut builder = QueryBuilder::new( + r#"INSERT INTO proposed_jit_orders + (auction_id, solution_uid, order_uid, sell_token, buy_token, limit_sell, limit_buy, side)"#, + ); + builder.push_values(jits.iter(), |mut b, j| { + b.push_bind(auction_id) + .push_bind(j.solution_uid) + .push_bind(j.order_uid) + .push_bind(j.sell_token) + .push_bind(j.buy_token) + .push_bind(&j.limit_sell) + .push_bind(&j.limit_buy) + .push_bind(j.side); + }); + builder.push(" ON CONFLICT (auction_id, solution_uid, order_uid) DO NOTHING;"); + builder.build().execute(ex.deref_mut()).await?; + Ok(()) +} + #[derive(sqlx::FromRow)] struct SolutionRow { uid: i64, diff --git a/crates/orderbook/src/database/mod.rs b/crates/orderbook/src/database/mod.rs index ef05171020..3f03825f9f 100644 --- a/crates/orderbook/src/database/mod.rs +++ b/crates/orderbook/src/database/mod.rs @@ -13,7 +13,7 @@ use { crate::database::orders::InsertionError, anyhow::Result, database::byte_array::ByteArray, - model::order::Order, + model::{DomainSeparator, order::Order}, shared::arguments::DB_MAX_CONNECTIONS_DEFAULT, sqlx::{Executor, PgConnection, PgPool, postgres::PgPoolOptions}, std::time::Duration, @@ -47,6 +47,10 @@ impl Default for Config { pub struct Postgres { pub pool: PgPool, pub config: Config, + /// Used to recover JIT order owners and compute their `order_uid` + /// before persisting fast-path quote competitions. Defaults to zero; + /// production callers must set it via `with_domain_separator`. + pub domain_separator: DomainSeparator, } // The implementation is split up into several modules which contain more public @@ -59,6 +63,7 @@ impl Postgres { .max_connections(config.max_pool_size) .connect_lazy(uri)?, config, + domain_separator: DomainSeparator::default(), }) } @@ -80,9 +85,15 @@ impl Postgres { }) .connect_lazy(uri)?, config, + domain_separator: DomainSeparator::default(), }) } + pub fn with_domain_separator(mut self, domain_separator: DomainSeparator) -> Self { + self.domain_separator = domain_separator; + self + } + async fn insert_order_app_data( order: &Order, ex: &mut PgConnection, diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 781a72b122..2b31ee395a 100644 --- a/crates/orderbook/src/database/orders.rs +++ b/crates/orderbook/src/database/orders.rs @@ -5,9 +5,9 @@ use { anyhow::{Context as _, Result}, app_data::AppDataHash, async_trait::async_trait, - bigdecimal::ToPrimitive, chrono::{DateTime, Utc}, database::{ + PgTransaction, byte_array::ByteArray, order_events::{OrderEvent, OrderEventLabel, insert_order_event}, orders::{self, FullOrder, OrderKind as DbOrderKind}, @@ -25,6 +25,7 @@ use { OrderStatus, OrderUid, }, + quote::QuoteId, signature::Signature, time::now_in_epoch_seconds, }, @@ -56,13 +57,22 @@ use { #[cfg_attr(test, mockall::automock)] #[async_trait::async_trait] pub trait OrderStoring: Send + Sync { - async fn insert_order(&self, order: &Order) -> Result<(), InsertionError>; + /// When `quote_id` is `Some`, the transient row is dropped from `quotes` + /// and its `auction_id` (if any) drives the fast-path competition patch. + /// The `order_quotes` row inherits that `auction_id` so there's a single + /// source of truth for the (auction, order) mapping. + async fn insert_order( + &self, + order: &Order, + quote_id: Option, + ) -> Result<(), InsertionError>; async fn cancel_orders(&self, order_uids: Vec, now: DateTime) -> Result<()>; async fn cancel_order(&self, order_uid: &OrderUid, now: DateTime) -> Result<()>; async fn replace_order( &self, old_order: &OrderUid, new_order: &Order, + quote_id: Option, ) -> Result<(), InsertionError>; async fn orders_for_tx(&self, tx_hash: &B256) -> Result>; /// All orders of a single user ordered by creation date descending (newest @@ -114,7 +124,11 @@ async fn cancel_order( Ok(()) } -async fn insert_order(order: &Order, ex: &mut PgConnection) -> Result<(), InsertionError> { +async fn insert_order( + order: &Order, + quote_id: Option, + ex: &mut PgTransaction<'_>, +) -> Result<(), InsertionError> { let order_uid = ByteArray(order.metadata.uid.0); insert_order_event( ex, @@ -192,23 +206,42 @@ async fn insert_order(order: &Order, ex: &mut PgConnection) -> Result<(), Insert .await .map_err(InsertionError::DbError)?; - if let Some(quote) = order.metadata.quote.as_ref() { + // delete the transient `quotes` row so every order is tied to exactly one + // quote — the data is then moved directly into permanent `order_quotes` + // table. + let quote = match quote_id { + Some(id) => database::quotes::delete_and_return_row(ex, id) + .await + .map_err(InsertionError::DbError)?, + None => None, + }; + + if let Some(quote) = quote { let db_quote = database::orders::Quote { order_uid, - // safe to unwrap as these values were converted from f64 previously - gas_amount: quote.gas_amount.to_f64().unwrap(), - gas_price: quote.gas_price.to_f64().unwrap(), - sell_token_price: quote.sell_token_price.to_f64().unwrap(), - sell_amount: u256_to_big_decimal("e.sell_amount), - buy_amount: u256_to_big_decimal("e.buy_amount), - solver: ByteArray(quote.solver.0.0), + gas_amount: quote.gas_amount, + gas_price: quote.gas_price, + sell_token_price: quote.sell_token_price, + sell_amount: quote.sell_amount, + buy_amount: quote.buy_amount, + solver: quote.solver, verified: quote.verified, - metadata: quote.metadata.clone(), - auction_id: None, + metadata: quote.metadata, + auction_id: quote.auction_id, }; database::orders::insert_quote(ex, &db_quote) .await .map_err(InsertionError::DbError)?; + + if let Some(auction_id) = quote.auction_id { + // the quote is associated with a auction competition indicating + // that this is going to be used for a fast path execution. + // not that we know the final order uid we can patch up the + // `proposed_trade_executions` rows. + database::solver_competition_v2::finalize_quote_competition(ex, auction_id, order_uid) + .await + .map_err(InsertionError::DbError)?; + } } Ok(()) @@ -216,7 +249,11 @@ async fn insert_order(order: &Order, ex: &mut PgConnection) -> Result<(), Insert #[async_trait::async_trait] impl OrderStoring for Postgres { - async fn insert_order(&self, order: &Order) -> Result<(), InsertionError> { + async fn insert_order( + &self, + order: &Order, + quote_id: Option, + ) -> Result<(), InsertionError> { let _timer = super::Metrics::get() .database_queries .with_label_values(&["insert_order"]) @@ -225,7 +262,7 @@ impl OrderStoring for Postgres { let mut connection = self.pool.acquire().await?; let mut ex = connection.begin().await?; - insert_order(order, &mut ex).await?; + insert_order(order, quote_id, &mut ex).await?; Self::insert_order_app_data(order, &mut ex).await?; ex.commit().await?; @@ -263,6 +300,7 @@ impl OrderStoring for Postgres { &self, old_order: &model::order::OrderUid, new_order: &model::order::Order, + quote_id: Option, ) -> anyhow::Result<(), super::orders::InsertionError> { let _timer = super::Metrics::get() .database_queries @@ -281,7 +319,7 @@ impl OrderStoring for Postgres { new_order.metadata.creation_date, ) .await?; - insert_order(&new_order, ex).await?; + insert_order(&new_order, quote_id, ex).await?; Self::insert_order_app_data(&new_order, ex).await?; Ok(()) @@ -936,7 +974,7 @@ mod tests { }, ..Default::default() }; - db.insert_order(&old_order).await.unwrap(); + db.insert_order(&old_order, None).await.unwrap(); let new_order = Order { data: OrderData { @@ -951,7 +989,7 @@ mod tests { }, ..Default::default() }; - db.replace_order(&old_order.metadata.uid, &new_order) + db.replace_order(&old_order.metadata.uid, &new_order, None) .await .unwrap(); @@ -998,7 +1036,7 @@ mod tests { }, ..Default::default() }; - db.insert_order(&old_order).await.unwrap(); + db.insert_order(&old_order, None).await.unwrap(); let new_order = Order { metadata: OrderMetadata { @@ -1009,11 +1047,11 @@ mod tests { }, ..Default::default() }; - db.insert_order(&new_order).await.unwrap(); + db.insert_order(&new_order, None).await.unwrap(); // Attempt to replace an old order with one that already exists should fail. let err = db - .replace_order(&old_order.metadata.uid, &new_order) + .replace_order(&old_order.metadata.uid, &new_order, None) .await .unwrap_err(); assert!(matches!(err, InsertionError::DuplicatedRecord)); @@ -1046,7 +1084,7 @@ mod tests { signature: Signature::default_with(SigningScheme::PreSign), ..Default::default() }; - db.insert_order(&order).await.unwrap(); + db.insert_order(&order, None).await.unwrap(); let order_status = || async { db.single_order(&order.metadata.uid) @@ -1133,9 +1171,9 @@ mod tests { } }; - db.insert_order(&order(1)).await.unwrap(); - db.insert_order(&order(2)).await.unwrap(); - db.insert_order(&order(3)).await.unwrap(); + db.insert_order(&order(1), None).await.unwrap(); + db.insert_order(&order(2), None).await.unwrap(); + db.insert_order(&order(3), None).await.unwrap(); assert_eq!(order_status(1).await, OrderStatus::Open); assert_eq!(order_status(2).await, OrderStatus::Open); @@ -1197,7 +1235,7 @@ mod tests { ..Default::default() }; - db.insert_order(&order).await.unwrap(); + db.insert_order(&order, None).await.unwrap(); let single_order = db.single_order(&uid).await.unwrap().unwrap(); assert_eq!( @@ -1260,7 +1298,7 @@ mod tests { ..Default::default() }; - db.insert_order(&order).await.unwrap(); + db.insert_order(&order, None).await.unwrap(); let single_order = db.single_order(&uid).await.unwrap().unwrap(); diff --git a/crates/orderbook/src/database/quotes.rs b/crates/orderbook/src/database/quotes.rs index ae22e76075..62186ad6fa 100644 --- a/crates/orderbook/src/database/quotes.rs +++ b/crates/orderbook/src/database/quotes.rs @@ -4,8 +4,9 @@ use { chrono::{DateTime, Utc}, model::quote::QuoteId, shared::{ - event_storing_helpers::{create_db_search_parameters, create_quote_row}, + event_storing_helpers::create_db_search_parameters, order_quoting::{QuoteCompetition, QuoteData, QuoteSearchParameters, QuoteStoring}, + quote_storage::save_quote_competition, }, }; @@ -17,11 +18,9 @@ impl QuoteStoring for Postgres { .with_label_values(&["save_quote"]) .start_timer(); - let mut ex = self.pool.acquire().await?; - let row = create_quote_row(&data)?; - let id = database::quotes::save(&mut ex, &row).await?; - // TODO populate `competition_auctions`, `proposed_solutions`, - // `proposed_trade_executions` + let mut tx = self.pool.begin().await?; + let id = save_quote_competition(&mut tx, data, &self.domain_separator).await?; + tx.commit().await?; Ok(id) } diff --git a/crates/orderbook/src/orderbook.rs b/crates/orderbook/src/orderbook.rs index e322c1ab2b..3ec59507f3 100644 --- a/crates/orderbook/src/orderbook.rs +++ b/crates/orderbook/src/orderbook.rs @@ -296,13 +296,14 @@ impl Orderbook { .await?; let order_uid = order.metadata.uid; + let quote_id = quote.as_ref().and_then(|q| q.id); // Check if it has to replace an existing order if let Some(old_order) = replaced_order { - self.replace_order(order, old_order).await? + self.replace_order(order, old_order, quote_id).await? } else { self.database - .insert_order(&order) + .insert_order(&order, quote_id) .await .map_err(|err| AddOrderError::from_insertion(err, &order))?; Metrics::on_order_operation(&order, OrderOperation::Created); @@ -442,6 +443,7 @@ impl Orderbook { &self, validated_new_order: Order, old_order: Order, + quote_id: Option, ) -> Result<(), AddOrderError> { validated_new_order .signature @@ -471,7 +473,7 @@ impl Orderbook { } self.database - .replace_order(&old_order.metadata.uid, &validated_new_order) + .replace_order(&old_order.metadata.uid, &validated_new_order, quote_id) .await .map_err(|err| AddOrderError::from_insertion(err, &validated_new_order))?; Metrics::on_order_operation(&old_order, OrderOperation::Cancelled); @@ -792,7 +794,7 @@ mod tests { let old_order = old_order.clone(); move |_| Ok(Some(old_order.clone())) }); - database.expect_replace_order().returning(|_, _| Ok(())); + database.expect_replace_order().returning(|_, _, _| Ok(())); let mut order_validator = MockOrderValidating::new(); order_validator @@ -816,7 +818,7 @@ mod tests { let database = crate::database::Postgres::try_new("postgresql://", Default::default()).unwrap(); database::clear_DANGER(&database.pool).await.unwrap(); - database.insert_order(&old_order).await.unwrap(); + database.insert_order(&old_order, None).await.unwrap(); let database_replica = database.clone(); let app_data = Arc::new(crate::app_data::Registry::new( diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index f16397511e..eb8e8a37a6 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -192,13 +192,15 @@ pub async fn run(config: Configuration) { statement_timeout: config.database.statement_timeout, }; let postgres_write = Postgres::try_new(config.database.write_url.as_str(), db_config.clone()) - .expect("failed to create database"); + .expect("failed to create database") + .with_domain_separator(domain_separator); let postgres_read = if let Some(db_read_url) = config.database.read_url && config.database.write_url != db_read_url { Postgres::try_new_with_timeout(db_read_url.as_str(), db_config) .expect("failed to create read replica database") + .with_domain_separator(domain_separator) } else { postgres_write.clone() }; diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 66f00b8303..993d20f1e4 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -12,6 +12,7 @@ pub mod interaction; pub mod order_creation_simulation; pub mod order_quoting; pub mod order_validation; +pub mod quote_storage; pub mod remaining_amounts; pub mod retry; pub mod token_list; diff --git a/crates/shared/src/order_quoting.rs b/crates/shared/src/order_quoting.rs index 77fd737c61..e32382b5e6 100644 --- a/crates/shared/src/order_quoting.rs +++ b/crates/shared/src/order_quoting.rs @@ -283,6 +283,11 @@ impl QuoteCompetition { } } + /// All quotes sorted from best to worst. Guaranteed to be non-empty. + pub fn quotes(&self) -> &[QuoteResponse] { + &self.quotes + } + /// Flattens the winning quote and metadata from the competition in /// a `QuoteData`. pub fn to_quote_data(&self) -> QuoteData { diff --git a/crates/shared/src/quote_storage.rs b/crates/shared/src/quote_storage.rs new file mode 100644 index 0000000000..cb8e34fdef --- /dev/null +++ b/crates/shared/src/quote_storage.rs @@ -0,0 +1,301 @@ +//! Persistence helpers for quote competitions. Shared between the orderbook +//! and autopilot `QuoteStoring::save` implementations so both flows land the +//! same rows in the DB. + +use { + crate::{ + db_order_conversions::order_kind_into, + event_storing_helpers::create_quote_row, + order_quoting::{QuoteCompetition, QuoteMetadata, QuoteResponse}, + }, + alloy::primitives::U256, + anyhow::{Context, Result}, + bigdecimal::{BigDecimal, Zero}, + database::{ + Address, + OrderUid, + PgTransaction, + auction::{Auction, AuctionId}, + byte_array::ByteArray, + solver_competition_v2::{ + self, + Order as CompetitionOrder, + QuoteJitOrder, + Solution as CompetitionSolution, + }, + }, + model::{ + DomainSeparator, + order::{OrderData, OrderKind}, + quote::QuoteId, + signature::Signature, + }, + number::conversions::u256_to_big_decimal, + price_estimation::{native::to_normalized_price, trade_finding::external::dto}, +}; + +/// Persists a quote row and, when the competition carries an `auction_id`, +/// also populates the associated `competition_auctions`, +/// `proposed_solutions`, `proposed_trade_executions`, and `proposed_jit_orders` +/// tables. +/// +/// Streaming quotes call this repeatedly for the same `auction_id`; any +/// prior rows for that id are deleted first so each call is idempotent and +/// the DB always reflects the latest competition. +/// +/// `domain_separator` is required to recover the owner of each JIT order +/// and compute its `order_uid` before persistence. +/// +/// The caller owns the transaction: this function performs no +/// `begin`/`commit` so callers can compose it with other statements. +pub async fn save_quote_competition( + tx: &mut PgTransaction<'_>, + data: QuoteCompetition, + domain_separator: &DomainSeparator, +) -> Result { + let row = create_quote_row(&data)?; + let id = database::quotes::save(&mut *tx, &row).await?; + + if let Some(auction_id) = data.metadata.auction_id { + write_competition_tables(tx, auction_id, &data, domain_separator).await?; + } + + Ok(id) +} + +async fn write_competition_tables( + tx: &mut PgTransaction<'_>, + auction_id: AuctionId, + data: &QuoteCompetition, + domain_separator: &DomainSeparator, +) -> Result<()> { + // Without a solution id we can't expect the solver to actually execute + // this solution, so storing any competition rows would be misleading. + let Some(winner) = data.quotes().first() else { + tracing::error!(auction_id, "fast path quote competition without any quotes"); + return Ok(()); + }; + if winner.solution_id.is_none() { + tracing::error!( + auction_id, + solver = ?winner.solver, + "winning quote is missing a solution id; skipping competition storage" + ); + return Ok(()); + } + + solver_competition_v2::delete_by_auction_id(tx, auction_id) + .await + .context("failed to clear previous quote competition rows")?; + + let sell_token = ByteArray(*data.request.sell_token.0); + let buy_token = ByteArray(*data.request.buy_token.0); + let (native_price_tokens, native_price_values) = build_native_prices(data); + + let auction = Auction { + id: auction_id, + // Block, deadline, and order_uids are unknown at quote time; real + // values are populated when the user places the order and a full + // auction runs. + block: 0, + deadline: 0, + order_uids: Vec::new(), + price_tokens: native_price_tokens, + price_values: native_price_values, + surplus_capturing_jit_order_owners: Vec::new(), + }; + database::auction::save(&mut *tx, auction) + .await + .context("failed to save competition_auctions row")?; + + let side = order_kind_into(data.request.kind); + let mut solutions: Vec = Vec::with_capacity(data.quotes().len()); + let mut jits: Vec = Vec::new(); + for (index, quote) in data.quotes().iter().enumerate() { + let Some(solution_id) = quote.solution_id else { + continue; + }; + let solution_uid = i64::try_from(index).expect("solution index fits in i64"); + + // Placeholder for the user's future order — the real uid is written + // when the order is placed. See `QuoteJitOrder` for why this row is + // needed even though the user order isn't a JIT. + let sell = u256_to_big_decimal("e.quoted_sell_amount); + let buy = u256_to_big_decimal("e.quoted_buy_amount); + let mut orders = vec![CompetitionOrder { + uid: Default::default(), + sell_token, + buy_token, + limit_sell: sell.clone(), + limit_buy: buy.clone(), + executed_sell: sell, + executed_buy: buy, + side, + }]; + + match encode_jit_orders(quote, solution_uid, domain_separator) { + Ok(quote_jits) => { + for (order, jit) in quote_jits { + orders.push(order); + jits.push(jit); + } + } + Err(err) => { + tracing::error!( + auction_id, + solver = ?quote.solver, + ?err, + "skipping solution: failed to encode JIT orders" + ); + continue; + } + } + + solutions.push(CompetitionSolution { + uid: solution_uid, + id: BigDecimal::from(solution_id), + solver: ByteArray(*quote.solver.0), + is_winner: index == 0, + filtered_out: false, + // No limit price exists at quote time, so surplus (and thus + // score) is undefined; store 0 as a placeholder. + score: BigDecimal::zero(), + orders, + // Natural single-trade UCP encoding for the user's placeholder + // trade. JIT clearing prices aren't reflected here — the solver's + // JIT execution amounts are stored directly on each JIT row. + price_tokens: vec![sell_token, buy_token], + price_values: vec![ + u256_to_big_decimal("e.quoted_buy_amount), + u256_to_big_decimal("e.quoted_sell_amount), + ], + }); + } + + solver_competition_v2::save_from_quote(tx, auction_id, &solutions, &jits) + .await + .context("failed to save quote competition solutions")?; + + Ok(()) +} + +/// Builds the pair of DB rows (trade execution + JIT metadata) for each +/// JIT order carried in a solver's quote response. Recovers the owner +/// from the signature and derives the `order_uid` so the JIT can be +/// joined against the future settlement. +fn encode_jit_orders( + quote: &QuoteResponse, + solution_uid: i64, + domain_separator: &DomainSeparator, +) -> Result> { + let QuoteMetadata::V1(metadata) = "e.metadata; + metadata + .jit_orders + .iter() + .map(|jit| build_jit_rows(jit, solution_uid, domain_separator)) + .collect() +} + +fn build_jit_rows( + jit: &dto::JitOrder, + solution_uid: i64, + domain_separator: &DomainSeparator, +) -> Result<(CompetitionOrder, QuoteJitOrder)> { + let kind = match jit.side { + dto::Side::Buy => OrderKind::Buy, + dto::Side::Sell => OrderKind::Sell, + }; + let order_data = OrderData { + sell_token: jit.sell_token, + buy_token: jit.buy_token, + receiver: Some(jit.receiver), + sell_amount: jit.sell_amount, + buy_amount: jit.buy_amount, + valid_to: jit.valid_to, + app_data: jit.app_data, + fee_amount: U256::ZERO, + kind, + partially_fillable: jit.partially_fillable, + sell_token_balance: jit.sell_token_source, + buy_token_balance: jit.buy_token_destination, + }; + let signature = Signature::from_bytes(jit.signing_scheme, &jit.signature) + .context("failed to parse JIT signature")?; + let owner = signature + .recover_owner(&jit.signature, domain_separator, &order_data.hash_struct()) + .context("failed to recover JIT order owner")?; + let order_uid: OrderUid = ByteArray(order_data.uid(domain_separator, owner).0); + + let sell_token: Address = ByteArray(*jit.sell_token.0); + let buy_token: Address = ByteArray(*jit.buy_token.0); + let limit_sell = u256_to_big_decimal(&jit.sell_amount); + let limit_buy = u256_to_big_decimal(&jit.buy_amount); + let (executed_sell, executed_buy) = jit_executed_amounts(jit)?; + let db_side = order_kind_into(kind); + + let order = CompetitionOrder { + uid: order_uid, + sell_token, + buy_token, + limit_sell: limit_sell.clone(), + limit_buy: limit_buy.clone(), + executed_sell, + executed_buy, + side: db_side, + }; + let jit_row = QuoteJitOrder { + solution_uid, + order_uid, + sell_token, + buy_token, + limit_sell, + limit_buy, + side: db_side, + }; + Ok((order, jit_row)) +} + +/// Derives (executed_sell, executed_buy) from the JIT's `executed_amount` +/// and limit price. The counterparty amount is computed proportionally at +/// the limit price — quote-time simulation doesn't produce clearing prices +/// for JIT trades, so we use the solver's declared limit rate as the +/// filled rate. +fn jit_executed_amounts(jit: &dto::JitOrder) -> Result<(BigDecimal, BigDecimal)> { + let (executed_sell, executed_buy) = match jit.side { + dto::Side::Sell => { + let executed_sell = jit.executed_amount; + let executed_buy = executed_sell + .checked_mul(jit.buy_amount) + .and_then(|v| v.checked_div(jit.sell_amount)) + .context("JIT sell executed amount overflow or zero sell limit")?; + (executed_sell, executed_buy) + } + dto::Side::Buy => { + let executed_buy = jit.executed_amount; + let executed_sell = executed_buy + .checked_mul(jit.sell_amount) + .and_then(|v| v.checked_div(jit.buy_amount)) + .context("JIT buy executed amount overflow or zero buy limit")?; + (executed_sell, executed_buy) + } + }; + Ok(( + u256_to_big_decimal(&executed_sell), + u256_to_big_decimal(&executed_buy), + )) +} + +fn build_native_prices(data: &QuoteCompetition) -> (Vec
, Vec) { + let mut tokens = Vec::with_capacity(2); + let mut values = Vec::with_capacity(2); + for (token, price) in [ + (data.request.sell_token, data.metadata.sell_token_price), + (data.request.buy_token, data.metadata.buy_token_price), + ] { + if let Some(value) = to_normalized_price(price) { + tokens.push(ByteArray(*token.0)); + values.push(u256_to_big_decimal(&value)); + } + } + (tokens, values) +} From 1f6147d39dd77a8f9b36c7f552d281fe2c837880 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 13 Aug 2026 13:12:36 +0000 Subject: [PATCH 4/5] e2e test for updating fast path data into DB --- crates/e2e/src/setup/colocation.rs | 1 + .../tests/e2e/fast_path_quote_promotion.rs | 424 ++++++++++++++++++ crates/e2e/tests/e2e/main.rs | 2 +- crates/e2e/tests/e2e/quote_fastpath_flags.rs | 114 ----- crates/orderbook/src/quoter.rs | 6 - crates/shared/src/order_validation.rs | 5 - 6 files changed, 426 insertions(+), 126 deletions(-) create mode 100644 crates/e2e/tests/e2e/fast_path_quote_promotion.rs delete mode 100644 crates/e2e/tests/e2e/quote_fastpath_flags.rs diff --git a/crates/e2e/src/setup/colocation.rs b/crates/e2e/src/setup/colocation.rs index 37247e8767..f63e94b62f 100644 --- a/crates/e2e/src/setup/colocation.rs +++ b/crates/e2e/src/setup/colocation.rs @@ -190,6 +190,7 @@ relative-slippage = "0.1" account = "{account}" merge-solutions = {merge_solutions} quote-using-limit-orders = {quote_using_limit_orders} +fast-path-enabled = true enable-simulation-bad-token-detection = true enable-metrics-bad-order-detection = true http-time-buffer = "100ms" diff --git a/crates/e2e/tests/e2e/fast_path_quote_promotion.rs b/crates/e2e/tests/e2e/fast_path_quote_promotion.rs new file mode 100644 index 0000000000..bdd93a39e8 --- /dev/null +++ b/crates/e2e/tests/e2e/fast_path_quote_promotion.rs @@ -0,0 +1,424 @@ +use { + crate::ethflow::ExtendedEthFlowOrder, + app_data::AppDataHash, + database::byte_array::ByteArray, + e2e::setup::*, + ethrpc::alloy::CallBuilderExt, + model::{ + order::{OrderCreation, OrderCreationAppData, OrderKind}, + quote::{ + OrderQuoteRequest, + OrderQuoteSide, + PriceQuality, + QuoteSigningScheme, + SellAmount, + Validity, + }, + signature::EcdsaSigningScheme, + }, + number::{nonzero::NonZeroU256, units::EthUnit}, + shared::web3::Web3, + std::ops::DerefMut, +}; + +#[tokio::test] +#[ignore] +async fn local_node_fast_path_quote_promotion() { + run_test(fast_path_quote_promotion).await; +} + +/// End-to-end check that a fast-path quote's competition rows are written at +/// quote time and then re-keyed to the real `order_uid` when the order is +/// placed. +async fn fast_path_quote_promotion(web3: Web3) { + let mut onchain = OnchainComponents::deploy(web3.clone()).await; + + let [solver] = onchain.make_solvers(10u64.eth()).await; + let [trader] = onchain.make_accounts(10u64.eth()).await; + let [token] = onchain + .deploy_tokens_with_weth_uni_v2_pools(1_000u64.eth(), 1_000u64.eth()) + .await; + + onchain + .contracts() + .weth + .approve(onchain.contracts().allowance, 3u64.eth()) + .from(trader.address()) + .send_and_watch() + .await + .unwrap(); + onchain + .contracts() + .weth + .deposit() + .from(trader.address()) + .value(3u64.eth()) + .send_and_watch() + .await + .unwrap(); + + tracing::info!("Starting services."); + let services = Services::new(&onchain).await; + services.start_protocol(solver.clone()).await; + + // 1) Fast-path quote request. The opt-in lives on the app-data metadata + // (`enableFastPath: true`), not on the quote payload directly. + tracing::info!("Quoting with enableFastPath"); + let fast_path_app_data = r#"{"metadata":{"enableFastPath":true}}"#; + let quote_sell_amount = 1u64.eth(); + let quote_request = OrderQuoteRequest { + from: trader.address(), + sell_token: *onchain.contracts().weth.address(), + buy_token: *token.address(), + side: OrderQuoteSide::Sell { + sell_amount: SellAmount::BeforeFee { + value: NonZeroU256::try_from(quote_sell_amount).unwrap(), + }, + }, + app_data: OrderCreationAppData::Full { + full: fast_path_app_data.to_string(), + }, + ..Default::default() + }; + let quote_response = services.submit_quote("e_request).await.unwrap(); + let quote_id = quote_response + .id + .expect("fast-path quote should carry an id"); + + // 2) The transient `quotes` row must be tagged with the fast-path `auction_id`, + // and that auction id must be present across all competition tables written + // at quote time. + tracing::info!("Verifying competition tables written at quote time"); + let auction_id = { + let mut db = services.db().acquire().await.unwrap(); + sqlx::query_scalar::<_, Option>("SELECT auction_id FROM quotes WHERE id = $1") + .bind(quote_id) + .fetch_one(db.deref_mut()) + .await + .unwrap() + .expect("fast-path quote row should carry an auction_id") + }; + + { + let mut db = services.db().acquire().await.unwrap(); + + let competition_auction: Option = + sqlx::query_scalar("SELECT id FROM competition_auctions WHERE id = $1") + .bind(auction_id) + .fetch_optional(db.deref_mut()) + .await + .unwrap(); + assert_eq!( + competition_auction, + Some(auction_id), + "competition_auctions row should exist for the fast-path auction" + ); + + let proposed_solutions: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM proposed_solutions WHERE auction_id = $1") + .bind(auction_id) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert!( + proposed_solutions > 0, + "expected at least one proposed_solutions row for the fast-path auction" + ); + + // Before the order is placed the placeholder uid (56 zero bytes) stands + // in for the yet-unknown user order. + let placeholder: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM proposed_trade_executions WHERE auction_id = $1 AND order_uid = \ + $2", + ) + .bind(auction_id) + .bind(ByteArray([0u8; 56])) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert!( + placeholder > 0, + "expected placeholder proposed_trade_executions row before order placement" + ); + } + + // 3) Place the order referencing this quote. + tracing::info!("Placing order with the fast-path quote_id"); + let order = OrderCreation { + quote_id: Some(quote_id), + sell_token: *onchain.contracts().weth.address(), + sell_amount: quote_sell_amount, + buy_token: *token.address(), + buy_amount: quote_response.quote.buy_amount, + valid_to: model::time::now_in_epoch_seconds() + 300, + kind: OrderKind::Sell, + app_data: OrderCreationAppData::Full { + full: fast_path_app_data.to_string(), + }, + ..Default::default() + } + .sign( + EcdsaSigningScheme::Eip712, + &onchain.contracts().domain_separator, + &trader.signer, + ); + let order_uid = services.create_order(&order).await.unwrap(); + + // 4) The promotion must: + // - drop the transient `quotes` row (single source of truth guarantee), + // - insert `order_quotes` with the same `auction_id`, + // - rewrite the placeholder in `proposed_trade_executions` to the real + // `order_uid` (and leave no placeholder behind). + tracing::info!("Verifying quote promotion + competition patch"); + let mut db = services.db().acquire().await.unwrap(); + + let quotes_row: Option = sqlx::query_scalar("SELECT id FROM quotes WHERE id = $1") + .bind(quote_id) + .fetch_optional(db.deref_mut()) + .await + .unwrap(); + assert!( + quotes_row.is_none(), + "transient quote row should have been deleted when the order was placed" + ); + + let order_quotes_auction_id: Option = + sqlx::query_scalar("SELECT auction_id FROM order_quotes WHERE order_uid = $1") + .bind(ByteArray(order_uid.0)) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert_eq!( + order_quotes_auction_id, + Some(auction_id), + "order_quotes should carry the promoted auction_id" + ); + + let patched: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM proposed_trade_executions WHERE auction_id = $1 AND order_uid = $2", + ) + .bind(auction_id) + .bind(ByteArray(order_uid.0)) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert!( + patched > 0, + "proposed_trade_executions should reference the real order_uid after placement" + ); + + let leftover_placeholder: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM proposed_trade_executions WHERE auction_id = $1 AND order_uid = $2", + ) + .bind(auction_id) + .bind(ByteArray([0u8; 56])) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert_eq!( + leftover_placeholder, 0, + "placeholder proposed_trade_executions row should have been overwritten" + ); + + // Log the winning trade execution against the amounts the user actually + // signed. Currently the executed amounts on the promoted row are still the + // ones captured at quote time; this makes the drift (if any) visible so a + // future change that patches them can be spotted from the test output. + let (winning_sell, winning_buy): (bigdecimal::BigDecimal, bigdecimal::BigDecimal) = + sqlx::query_as( + "SELECT pte.executed_sell, pte.executed_buy + FROM proposed_trade_executions pte + JOIN proposed_solutions ps + ON ps.auction_id = pte.auction_id AND ps.uid = pte.solution_uid + WHERE pte.auction_id = $1 + AND pte.order_uid = $2 + AND ps.is_winner = TRUE", + ) + .bind(auction_id) + .bind(ByteArray(order_uid.0)) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + let signed = order.data(); + tracing::info!( + %winning_sell, + %winning_buy, + signed_sell = %signed.sell_amount, + signed_buy = %signed.buy_amount, + "winning proposed_trade_execution vs. signed order amounts" + ); +} + +#[tokio::test] +#[ignore] +async fn local_node_fast_path_ethflow_promotion() { + run_test(fast_path_ethflow_promotion).await; +} + +/// Same invariants as `fast_path_quote_promotion`, but the order is placed +/// on-chain via the ethflow contract instead of `POST /orders`. Verifies +/// that the autopilot's onchain-event ingestion path also promotes the +/// transient quote and patches the competition placeholder. +async fn fast_path_ethflow_promotion(web3: Web3) { + let mut onchain = OnchainComponents::deploy(web3.clone()).await; + + let [solver] = onchain.make_solvers(2u64.eth()).await; + let [trader] = onchain.make_accounts(2u64.eth()).await; + let [token] = onchain + .deploy_tokens_with_weth_uni_v2_pools(1_000u64.eth(), 1_000u64.eth()) + .await; + + tracing::info!("Starting services."); + let services = Services::new(&onchain).await; + services.start_protocol(solver).await; + + // 1) Register the app-data JSON that carries the fast-path opt-in and grab the + // returned hash — that's what the ethflow contract will emit on-chain. + tracing::info!("Registering fast-path app data"); + let fast_path_app_data = r#"{"metadata":{"enableFastPath":true}}"#; + let app_data_hex = services + .put_app_data(None, fast_path_app_data) + .await + .unwrap(); + let app_data_hash = AppDataHash( + const_hex::decode(&app_data_hex[2..]) + .unwrap() + .try_into() + .unwrap(), + ); + + // 2) Fast-path quote for the future ethflow order. Ethflow orders sign via + // EIP-1271 (owner is the ethflow contract), so the quote must be requested + // with that signing scheme. + tracing::info!("Quoting with enableFastPath"); + let sell_amount = 1u64.eth(); + let quote_request = OrderQuoteRequest { + from: trader.address(), + sell_token: *onchain.contracts().weth.address(), + buy_token: *token.address(), + receiver: Some(trader.address()), + validity: Validity::For(3600), + app_data: OrderCreationAppData::Hash { + hash: app_data_hash, + }, + signing_scheme: QuoteSigningScheme::Eip1271 { + onchain_order: true, + verification_gas_limit: 0, + }, + side: OrderQuoteSide::Sell { + sell_amount: SellAmount::AfterFee { + value: NonZeroU256::try_from(sell_amount).unwrap(), + }, + }, + price_quality: PriceQuality::Optimal, + ..Default::default() + }; + let quote_response = services.submit_quote("e_request).await.unwrap(); + let quote_id = quote_response + .id + .expect("fast-path quote should carry an id"); + + // 3) Same competition-tables invariant as the API-based flow. + tracing::info!("Verifying competition tables written at quote time"); + let auction_id = { + let mut db = services.db().acquire().await.unwrap(); + sqlx::query_scalar::<_, Option>("SELECT auction_id FROM quotes WHERE id = $1") + .bind(quote_id) + .fetch_one(db.deref_mut()) + .await + .unwrap() + .expect("fast-path quote row should carry an auction_id") + }; + { + let mut db = services.db().acquire().await.unwrap(); + let placeholder: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM proposed_trade_executions WHERE auction_id = $1 AND order_uid = \ + $2", + ) + .bind(auction_id) + .bind(ByteArray([0u8; 56])) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert!( + placeholder > 0, + "expected placeholder proposed_trade_executions row before ethflow order placement" + ); + } + + // 4) Place the ethflow order on-chain and wait for the autopilot to index it. + // Ethflow orders don't reach the DB via `POST /orders` — they show up when + // the autopilot picks up the `OrderPlacement` event. + tracing::info!("Placing ethflow order on-chain"); + let valid_to = chrono::offset::Utc::now().timestamp() as u32 + 3600; + let ethflow_order = + ExtendedEthFlowOrder::from_quote("e_response, valid_to).include_slippage_bps(300); + let ethflow_contract = onchain.contracts().ethflows.first().unwrap(); + ethflow_order + .mine_order_creation(trader.address(), ethflow_contract) + .await; + + tracing::info!("Waiting for autopilot to index the ethflow order"); + let order_uid = ethflow_order + .uid(onchain.contracts(), ethflow_contract) + .await; + wait_for_condition(TIMEOUT, || async { + onchain.mint_block().await; + services.get_order(&order_uid).await.is_ok() + }) + .await + .unwrap(); + + // 5) The onchain-event ingestion path must apply the same promotion the + // orderbook does for API orders. + tracing::info!("Verifying quote promotion + competition patch for ethflow"); + let mut db = services.db().acquire().await.unwrap(); + + let quotes_row: Option = sqlx::query_scalar("SELECT id FROM quotes WHERE id = $1") + .bind(quote_id) + .fetch_optional(db.deref_mut()) + .await + .unwrap(); + assert!( + quotes_row.is_none(), + "transient quote row should have been deleted when the ethflow order was indexed" + ); + + let order_quotes_auction_id: Option = + sqlx::query_scalar("SELECT auction_id FROM order_quotes WHERE order_uid = $1") + .bind(ByteArray(order_uid.0)) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert_eq!( + order_quotes_auction_id, + Some(auction_id), + "order_quotes should carry the promoted auction_id for ethflow orders" + ); + + let patched: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM proposed_trade_executions WHERE auction_id = $1 AND order_uid = $2", + ) + .bind(auction_id) + .bind(ByteArray(order_uid.0)) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert!( + patched > 0, + "proposed_trade_executions should reference the real ethflow order_uid after placement" + ); + + let leftover_placeholder: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM proposed_trade_executions WHERE auction_id = $1 AND order_uid = $2", + ) + .bind(auction_id) + .bind(ByteArray([0u8; 56])) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + assert_eq!( + leftover_placeholder, 0, + "placeholder proposed_trade_executions row should have been overwritten" + ); +} diff --git a/crates/e2e/tests/e2e/main.rs b/crates/e2e/tests/e2e/main.rs index 463b11bfc4..e56308b091 100644 --- a/crates/e2e/tests/e2e/main.rs +++ b/crates/e2e/tests/e2e/main.rs @@ -19,6 +19,7 @@ mod eip4626; mod eth_integration; mod eth_safe; mod ethflow; +mod fast_path_quote_promotion; mod hooks; mod jit_orders; mod limit_orders; @@ -34,7 +35,6 @@ mod partially_fillable_pool; mod place_order_with_quote; mod pool_indexer; mod protocol_fee; -mod quote_fastpath_flags; mod quote_verification; mod quoting; mod refunder; diff --git a/crates/e2e/tests/e2e/quote_fastpath_flags.rs b/crates/e2e/tests/e2e/quote_fastpath_flags.rs deleted file mode 100644 index f8230ff233..0000000000 --- a/crates/e2e/tests/e2e/quote_fastpath_flags.rs +++ /dev/null @@ -1,114 +0,0 @@ -use { - configs::{ - order_quoting::{ExternalSolver, OrderQuoting}, - shared::SharedConfig, - test_util::TestDefault, - }, - e2e::setup::{OnchainComponents, Services, run_test}, - eth_domain_types::Address, - model::{ - order::{OrderCreation, OrderCreationAppData, OrderKind}, - quote::{OrderQuoteRequest, OrderQuoteSide, SellAmount}, - signature::EcdsaSigningScheme, - }, - number::units::EthUnit, - reqwest::StatusCode, - shared::web3::Web3, -}; - -#[tokio::test] -#[ignore] -async fn local_node_fast_path_flags_rejected() { - run_test(fast_path_flags_rejected).await; -} - -/// Verifies the orderbook rejects the not-yet-supported `fast_path` quote flag -/// and `enableFastPath` app-data field. -async fn fast_path_flags_rejected(web3: Web3) { - let mut onchain = OnchainComponents::deploy(web3).await; - let [trader] = onchain.make_accounts(1u64.eth()).await; - 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_solver", - )]), - shared: SharedConfig { - gas_estimators: vec![TestDefault::test_default()], - ..Default::default() - }, - ..configs::orderbook::Configuration::test_default() - }) - .await; - - // Distinct, non-native addresses so partial_validate passes before the - // fast-path check fires. - let sell_token = Address::with_last_byte(2); - let buy_token = Address::with_last_byte(3); - - let base_quote = || OrderQuoteRequest { - from: trader.address(), - sell_token, - buy_token, - side: OrderQuoteSide::Sell { - sell_amount: SellAmount::BeforeFee { - value: 1u64.eth().try_into().unwrap(), - }, - }, - ..Default::default() - }; - - // --- quote: enableFastPath in app data --- - let err = services - .submit_quote(&OrderQuoteRequest { - app_data: OrderCreationAppData::Full { - full: r#"{"metadata":{"enableFastPath":true}}"#.to_string(), - }, - ..base_quote() - }) - .await - .unwrap_err(); - assert_eq!(err.0, StatusCode::BAD_REQUEST); - assert!( - err.1.contains("enableFastPath"), - "error body should mention enableFastPath, got: {}", - err.1 - ); - - // For order tests, validate_app_data fires before signature verification so - // we just need structurally valid (but cryptographically incorrect) orders. - let valid_to = model::time::now_in_epoch_seconds() + 300; - - let make_order = |app_data_str: &str| -> OrderCreation { - OrderCreation { - sell_token, - sell_amount: 1u64.eth(), - buy_token, - buy_amount: 1u64.eth(), - valid_to, - kind: OrderKind::Sell, - app_data: OrderCreationAppData::Full { - full: app_data_str.to_string(), - }, - ..Default::default() - } - .sign( - EcdsaSigningScheme::Eip712, - &onchain.contracts().domain_separator, - &trader.signer, - ) - }; - - // --- order: enableFastPath in app data --- - let err = services - .create_order(&make_order(r#"{"metadata":{"enableFastPath":true}}"#)) - .await - .unwrap_err(); - assert_eq!(err.0, StatusCode::BAD_REQUEST); - assert!( - err.1.contains("enableFastPath"), - "error body should mention enableFastPath, got: {}", - err.1 - ); -} diff --git a/crates/orderbook/src/quoter.rs b/crates/orderbook/src/quoter.rs index aaf86748a6..5899bc5834 100644 --- a/crates/orderbook/src/quoter.rs +++ b/crates/orderbook/src/quoter.rs @@ -275,12 +275,6 @@ impl QuoteHandler { let valid_to = order.valid_to; self.order_validator.partial_validate(order).await?; - if app_data.inner.protocol.enable_fast_path { - return Err(OrderQuoteError::AppData(AppDataValidationError::Invalid( - anyhow::anyhow!("'enableFastPath' is not yet supported"), - ))); - } - // Emit only after validation succeeds so we don't announce requests // that never reach the estimator (invalid app-data / order data return // early above). This is best-effort correlation, not a guarantee: if diff --git a/crates/shared/src/order_validation.rs b/crates/shared/src/order_validation.rs index 6372d759c0..231a45fe44 100644 --- a/crates/shared/src/order_validation.rs +++ b/crates/shared/src/order_validation.rs @@ -763,11 +763,6 @@ impl OrderValidating for OrderValidator { OrderCreationAppData::Full { full } => validate(full)?, }; - if app_data.protocol.enable_fast_path { - return Err(AppDataValidationError::Invalid(anyhow::anyhow!( - "'enableFastPath' is not yet supported" - ))); - } let interactions = self.custom_interactions(&app_data.protocol.hooks); Ok(OrderAppData { From 578c73d21af885dd442c71d93c719dfdf2f7c9eb Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Fri, 14 Aug 2026 08:48:27 +0000 Subject: [PATCH 5/5] fixup domain separator initialization in DB --- crates/autopilot/src/database/mod.rs | 16 ++++++------- crates/autopilot/src/run.rs | 28 +++++++++++----------- crates/orderbook/src/database/mod.rs | 32 +++++++++++++++++-------- crates/orderbook/src/database/orders.rs | 12 +++++----- crates/orderbook/src/orderbook.rs | 3 +-- crates/orderbook/src/run.rs | 12 ++++++---- 6 files changed, 58 insertions(+), 45 deletions(-) diff --git a/crates/autopilot/src/database/mod.rs b/crates/autopilot/src/database/mod.rs index 9388d471c6..ea186b4b6f 100644 --- a/crates/autopilot/src/database/mod.rs +++ b/crates/autopilot/src/database/mod.rs @@ -48,7 +48,11 @@ pub struct Postgres { } impl Postgres { - pub async fn new(url: &str, config: Config) -> sqlx::Result { + pub async fn new( + url: &str, + config: Config, + domain_separator: DomainSeparator, + ) -> sqlx::Result { let pool = PgPoolOptions::new() .max_connections(config.max_pool_size.get()) .connect(url) @@ -59,15 +63,10 @@ impl Postgres { Ok(Self { pool, config, - domain_separator: DomainSeparator::default(), + domain_separator, }) } - pub fn with_domain_separator(mut self, domain_separator: DomainSeparator) -> Self { - self.domain_separator = domain_separator; - self - } - fn start_db_metrics_job(pool: PgPool) { tokio::spawn(async move { let mut ticker = tokio::time::interval(Duration::from_secs(5)); @@ -86,8 +85,9 @@ impl Postgres { }); } + #[cfg(test)] pub async fn with_defaults() -> sqlx::Result { - Self::new("postgresql://", Default::default()).await + Self::new("postgresql://", Default::default(), Default::default()).await } #[instrument(skip_all)] diff --git a/crates/autopilot/src/run.rs b/crates/autopilot/src/run.rs index 601835e501..914b0f4c9b 100644 --- a/crates/autopilot/src/run.rs +++ b/crates/autopilot/src/run.rs @@ -189,19 +189,6 @@ pub async fn start(args: impl Iterator) { /// Assumes tracing and metrics registry have already been set up. pub async fn run(config: Configuration, shutdown_controller: ShutdownController) { assert!(config.shadow.is_none(), "cannot run in shadow mode"); - let mut db_write = Postgres::new( - config.database.write_url.as_str(), - crate::database::Config { - insert_batch_size: config.database.insert_batch_size, - max_pool_size: config.database.max_connections, - }, - ) - .await - .unwrap(); - - // If the DB is in read-only mode, running ANALYZE is not possible and will - // trigger and error https://www.postgresql.org/docs/current/hot-standby.html - crate::database::run_database_metrics_work(db_write.clone()); let http_factory = HttpClientFactory::from(config.http_client); let ethrpc_args = shared::web3::Arguments::from(&config.shared.ethrpc); @@ -258,7 +245,20 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController) .expect("Couldn't get vault relayer address"); let domain_separator = DomainSeparator::new(chain_id, *eth.contracts().settlement().address()); - db_write.domain_separator = domain_separator; + let db_write = Postgres::new( + config.database.write_url.as_str(), + crate::database::Config { + insert_batch_size: config.database.insert_batch_size, + max_pool_size: config.database.max_connections, + }, + domain_separator, + ) + .await + .unwrap(); + + // If the DB is in read-only mode, running ANALYZE is not possible and will + // trigger and error https://www.postgresql.org/docs/current/hot-standby.html + crate::database::run_database_metrics_work(db_write.clone()); let chain = Chain::try_from(chain_id).expect("incorrect chain ID"); diff --git a/crates/orderbook/src/database/mod.rs b/crates/orderbook/src/database/mod.rs index 3f03825f9f..ab6f4d90c6 100644 --- a/crates/orderbook/src/database/mod.rs +++ b/crates/orderbook/src/database/mod.rs @@ -57,19 +57,23 @@ pub struct Postgres { // methods. impl Postgres { - pub fn try_new(uri: &str, config: Config) -> Result { + pub fn try_new(uri: &str, config: Config, domain_separator: DomainSeparator) -> Result { Ok(Self { pool: PgPoolOptions::new() .max_connections(config.max_pool_size) .connect_lazy(uri)?, config, - domain_separator: DomainSeparator::default(), + domain_separator, }) } /// Creates a Postgres connection pool, but applies the [`Config`]'s /// `statement_timeout` to all queries. - pub fn try_new_with_timeout(uri: &str, config: Config) -> Result { + pub fn try_new_with_timeout( + uri: &str, + config: Config, + domain_separator: DomainSeparator, + ) -> Result { let read_query_timeout = config.statement_timeout; Ok(Self { @@ -85,15 +89,10 @@ impl Postgres { }) .connect_lazy(uri)?, config, - domain_separator: DomainSeparator::default(), + domain_separator, }) } - pub fn with_domain_separator(mut self, domain_separator: DomainSeparator) -> Self { - self.domain_separator = domain_separator; - self - } - async fn insert_order_app_data( order: &Order, ex: &mut PgConnection, @@ -110,6 +109,18 @@ impl Postgres { } Ok(()) } + + #[cfg(test)] + pub fn with_defaults() -> Result { + let config = Config::default(); + Ok(Self { + pool: PgPoolOptions::new() + .max_connections(config.max_pool_size) + .connect_lazy("postgres://")?, + config, + domain_separator: Default::default(), + }) + } } #[derive(prometheus_metric_storage::MetricStorage)] @@ -136,7 +147,8 @@ mod tests { statement_timeout: Duration::from_millis(100), ..Default::default() }; - let db = Postgres::try_new_with_timeout("postgresql://", config).unwrap(); + let db = + Postgres::try_new_with_timeout("postgresql://", config, Default::default()).unwrap(); let mut conn = db.pool.acquire().await.unwrap(); // A fast query should succeed. diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 2b31ee395a..9bd7fdfe94 100644 --- a/crates/orderbook/src/database/orders.rs +++ b/crates/orderbook/src/database/orders.rs @@ -959,7 +959,7 @@ mod tests { async fn postgres_replace_order() { let owner = Address::repeat_byte(0x77); - let db = Postgres::try_new("postgresql://", Default::default()).unwrap(); + let db = Postgres::with_defaults().unwrap(); database::clear_DANGER(&db.pool).await.unwrap(); let old_order = Order { @@ -1025,7 +1025,7 @@ mod tests { async fn postgres_replace_order_no_cancellation_on_error() { let owner = Address::repeat_byte(0x77); - let db = Postgres::try_new("postgresql://", Default::default()).unwrap(); + let db = Postgres::with_defaults().unwrap(); database::clear_DANGER(&db.pool).await.unwrap(); let old_order = Order { @@ -1069,7 +1069,7 @@ mod tests { #[tokio::test] #[ignore] async fn postgres_presignature_status() { - let db = Postgres::try_new("postgresql://", Default::default()).unwrap(); + let db = Postgres::with_defaults().unwrap(); database::clear_DANGER(&db.pool).await.unwrap(); let uid = OrderUid([0u8; 56]); let order = Order { @@ -1142,7 +1142,7 @@ mod tests { #[tokio::test] #[ignore] async fn postgres_cancel_orders() { - let db = Postgres::try_new("postgresql://", Default::default()).unwrap(); + let db = Postgres::with_defaults().unwrap(); database::clear_DANGER(&db.pool).await.unwrap(); // Define some helper closures to make the test easier to read. @@ -1191,7 +1191,7 @@ mod tests { #[tokio::test] #[ignore] async fn postgres_insert_orders_with_interactions() { - let db = Postgres::try_new("postgresql://", Default::default()).unwrap(); + let db = Postgres::with_defaults().unwrap(); database::clear_DANGER(&db.pool).await.unwrap(); let interaction = |byte: u8| InteractionData { @@ -1248,7 +1248,7 @@ mod tests { #[tokio::test] #[ignore] async fn postgres_insert_orders_with_interactions_and_verified() { - let db = Postgres::try_new("postgresql://", Default::default()).unwrap(); + let db = Postgres::with_defaults().unwrap(); database::clear_DANGER(&db.pool).await.unwrap(); let quote = Quote { diff --git a/crates/orderbook/src/orderbook.rs b/crates/orderbook/src/orderbook.rs index 3ec59507f3..795b3446f5 100644 --- a/crates/orderbook/src/orderbook.rs +++ b/crates/orderbook/src/orderbook.rs @@ -815,8 +815,7 @@ mod tests { )) }); - let database = - crate::database::Postgres::try_new("postgresql://", Default::default()).unwrap(); + let database = crate::database::Postgres::with_defaults().unwrap(); database::clear_DANGER(&database.pool).await.unwrap(); database.insert_order(&old_order, None).await.unwrap(); diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index eb8e8a37a6..d272959fd0 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -191,16 +191,18 @@ pub async fn run(config: Configuration) { max_pool_size: config.database.max_connections.get(), statement_timeout: config.database.statement_timeout, }; - let postgres_write = Postgres::try_new(config.database.write_url.as_str(), db_config.clone()) - .expect("failed to create database") - .with_domain_separator(domain_separator); + let postgres_write = Postgres::try_new( + config.database.write_url.as_str(), + db_config.clone(), + domain_separator, + ) + .expect("failed to create database"); let postgres_read = if let Some(db_read_url) = config.database.read_url && config.database.write_url != db_read_url { - Postgres::try_new_with_timeout(db_read_url.as_str(), db_config) + Postgres::try_new_with_timeout(db_read_url.as_str(), db_config, domain_separator) .expect("failed to create read replica database") - .with_domain_separator(domain_separator) } else { postgres_write.clone() };