Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions crates/autopilot/src/database/auction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand All @@ -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)
}

Expand Down
13 changes: 13 additions & 0 deletions crates/database/src/quotes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Quote>, 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 {
Expand Down
40 changes: 40 additions & 0 deletions crates/database/src/solver_competition_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +367 to +371

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL you can do this

"#;
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,
Expand Down
11 changes: 5 additions & 6 deletions crates/orderbook/src/database/quotes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};

Expand All @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion crates/shared/src/event_storing_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub fn create_quote_row(competition: &QuoteCompetition) -> Result<DbQuote> {
solver: ByteArray(*data.solver.0),
verified: data.verified,
metadata: data.metadata.try_into()?,
auction_id: None,
auction_id: data.auction_id,
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions crates/shared/src/order_quoting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
161 changes: 161 additions & 0 deletions crates/shared/src/quote_storage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! Persistence helpers for quote competitions. Shared between the orderbook

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weren't we trying to reduce the amount of code we put in the share crate? Could this module live in the database crate directly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given we introduce a fast path db module later, I feel like the part of the code that is fake auction specific should actually live there. Wdyt?

//! 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<QuoteId> {
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(());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Ok here is misleading, the log already points at it with the error level even

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")?;
Comment on lines +81 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synthetic quote auctions are written into competition_auctions with block: 0, deadline: 0 and an id drawn from the same auctions sequence as real auctions (get_next_auction_id). This leaks into the "latest" queries that key off that table:

  • solver_competition_v2::load_latest (public GET /v2/solver_competition/latest) does ORDER BY id DESC filtered by deadline <= current_block. A synthetic quote auction has the highest id and deadline = 0 <= current_block, so right after any fast-path quote the endpoint returns a never-settled competition with block = 0, placeholder scores, and a zero order_uid.
  • auction::fetch_latest_prices (MAX(id)) and auction::fetch_latest_token_price ("most recent auction that priced the token") will likewise start resolving against these 2-token synthetic rows.

Quotes that never become orders leave these block = 0 rows behind permanently. Is polluting the latest-competition / latest-price paths intended here, or should synthetic auctions be excluded from those queries (or given a sentinel deadline)? Unlike order_uid/score, block/deadline aren't called out as a follow-up TODO.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a reasonable concern. Maybe differentiating those synthetic auctions more explicitly (even via a fast path column) may make sense.

Why don't we set block and deadline? Isn't there a well defined exclusivity period?


let side = order_kind_into(data.request.kind);
let mut solutions: Vec<CompetitionSolution> = Vec::with_capacity(data.quotes().len());
for (index, quote) in data.quotes().iter().enumerate() {
let Some(solution_id) = quote.solution_id else {
continue;
};
Comment on lines +100 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really asks for a filter_map but then it becomes more verbose than it already is

let solution_uid = i64::try_from(index).expect("solution index fits in i64");
Comment on lines +100 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-winner quotes that lack a solution_id are silently skipped, but solution_uid is derived from the original enumerate() index. So if e.g. index 1 is skipped, the persisted uids become 0, 2, 3, …. load_by_id then computes ranking = uid + 1, producing gaps (1, 3, 4) in the ranking presented via the competition API. Consider assigning uid from a running counter over the kept solutions so ranks stay contiguous.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the translation is done, because solver chosen solution ids might not be unique. I think this is another example of why we should chose the quote id and simply reuse it instead of relying on solvers to generate one. It will also help our internal tracing.


// Placeholder for the user's future order — the real uid is written
// when the order is placed.
let sell = u256_to_big_decimal(&quote.quoted_sell_amount);
let buy = u256_to_big_decimal(&quote.quoted_buy_amount);
let orders = vec![CompetitionOrder {
uid: Default::default(),
Comment on lines +106 to +111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nano nit:

Suggested change
// Placeholder for the user's future order — the real uid is written
// when the order is placed.
let sell = u256_to_big_decimal(&quote.quoted_sell_amount);
let buy = u256_to_big_decimal(&quote.quoted_buy_amount);
let orders = vec![CompetitionOrder {
uid: Default::default(),
let sell = u256_to_big_decimal(&quote.quoted_sell_amount);
let buy = u256_to_big_decimal(&quote.quoted_buy_amount);
let orders = vec![CompetitionOrder {
// Placeholder for the user's future order — the real uid is written
// when the order is placed.
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(&quote.quoted_buy_amount),
u256_to_big_decimal(&quote.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<Address>, Vec<BigDecimal>) {
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)
}
Loading