diff --git a/crates/autopilot/src/database/auction.rs b/crates/autopilot/src/database/auction.rs index 76794a49c0..c3b8075d26 100644 --- a/crates/autopilot/src/database/auction.rs +++ b/crates/autopilot/src/database/auction.rs @@ -11,8 +11,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}, }; @@ -25,9 +26,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).await?; + tx.commit().await?; Ok(id) } diff --git a/crates/database/src/quotes.rs b/crates/database/src/quotes.rs index 02c235b4be..ca10e6ccc8 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 d5aa52db74..c871cb74fc 100644 --- a/crates/database/src/solver_competition_v2.rs +++ b/crates/database/src/solver_competition_v2.rs @@ -355,6 +355,46 @@ async fn save_jit_orders( 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(()) +} + +/// Persists competition data derived from a fast-path quote response. +/// `solutions[i].orders` carries the user's placeholder trade — written to +/// `proposed_trade_executions`. JIT orders proposed by solvers are not +/// persisted; they live in the `quotes.metadata` JSON blob if needed. +#[instrument(skip_all)] +pub async fn save_from_quote( + ex: &mut PgTransaction<'_>, + auction_id: AuctionId, + solutions: &[Solution], +) -> Result<(), sqlx::Error> { + if solutions.is_empty() { + return Ok(()); + } + save_solutions(ex, auction_id, solutions).await?; + save_trade_executions(ex, auction_id, solutions).await?; + Ok(()) +} + #[derive(sqlx::FromRow)] struct SolutionRow { uid: i64, diff --git a/crates/orderbook/src/database/quotes.rs b/crates/orderbook/src/database/quotes.rs index ae22e76075..43d9e4dddc 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).await?; + tx.commit().await?; Ok(id) } 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, }) } 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 e759851b3a..8b56b473f9 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..f19103a903 --- /dev/null +++ b/crates/shared/src/quote_storage.rs @@ -0,0 +1,161 @@ +//! 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, + }, + anyhow::{Context, Result}, + bigdecimal::{BigDecimal, Zero}, + database::{ + Address, + PgTransaction, + auction::{Auction, AuctionId}, + byte_array::ByteArray, + solver_competition_v2::{self, Order as CompetitionOrder, Solution as CompetitionSolution}, + }, + model::quote::QuoteId, + number::conversions::u256_to_big_decimal, + price_estimation::native::to_normalized_price, +}; + +/// Persists a quote row and, when the competition carries an `auction_id`, +/// also populates the associated `competition_auctions`, +/// `proposed_solutions`, and `proposed_trade_executions` 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. +/// +/// JIT orders proposed by solvers are intentionally *not* persisted here: +/// they can be recovered from the `quotes` table's `metadata` JSON blob if +/// needed, and the driver re-encodes them at settle time. +/// +/// 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, +) -> 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).await?; + } + + Ok(id) +} + +async fn write_competition_tables( + tx: &mut PgTransaction<'_>, + auction_id: AuctionId, + data: &QuoteCompetition, +) -> 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(), + penalty_caps_native: Some(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()); + 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. + let sell = u256_to_big_decimal("e.quoted_sell_amount); + let buy = u256_to_big_decimal("e.quoted_buy_amount); + let 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, + }]; + + 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. + 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) + .await + .context("failed to save quote competition solutions")?; + + Ok(()) +} + +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) +}