diff --git a/crates/autopilot/src/infra/persistence/mod.rs b/crates/autopilot/src/infra/persistence/mod.rs index 356d4f9bee..81a3e772b3 100644 --- a/crates/autopilot/src/infra/persistence/mod.rs +++ b/crates/autopilot/src/infra/persistence/mod.rs @@ -38,7 +38,7 @@ use { eth_domain_types as eth, futures::{StreamExt, TryStreamExt}, number::conversions::{big_decimal_to_u256, u256_to_big_decimal, u256_to_big_uint}, - shared::db_order_conversions::full_order_into_model_order, + shared::db_order_conversions::{fast_path_order_into_model, full_order_into_model_order}, std::{ collections::{HashMap, HashSet}, ops::DerefMut, @@ -1037,6 +1037,143 @@ impl Persistence { .map(|o| crate::domain::OrderUid(o.0)) .collect()) } + + /// Recovers what's needed to settle a fast-path order via the driver's + /// `/settle`, or `None` when `uid` is not a fast-path order (its quote's + /// competition was not persisted). + pub async fn fast_path_order( + &self, + uid: domain::OrderUid, + ) -> anyhow::Result> { + let _timer = Metrics::get() + .database_queries + .with_label_values(&["fast_path_order"]) + .start_timer(); + + let mut ex = self.postgres.pool.acquire().await.context("acquire")?; + let key = ByteArray(uid.0); + + let Some(row) = database::fast_path::single_fast_path_order(&mut ex, &key).await? else { + return Ok(None); + }; + + let model_order = fast_path_order_into_model(&row)?; + + let native_prices = row + .price_tokens + .iter() + .zip(&row.price_values) + .map(|(token, value)| { + let price = big_decimal_to_u256(value).context("invalid native price")?; + anyhow::Ok((eth::Address::from(token.0), price)) + }) + .collect::>>()?; + + Ok(Some(FastPathOrder { + model_order, + auction_id: row.auction_id, + solution_id: row + .solution_id + .to_u64() + .context("solution id out of range")?, + solution_uid: row + .solution_uid + .to_usize() + .context("solution uid out of range")?, + solver: eth::Address::from(row.solver.0), + raw_sell: big_decimal_to_u256(&row.executed_sell) + .context("invalid executed sell amount")?, + raw_buy: big_decimal_to_u256(&row.executed_buy) + .context("invalid executed buy amount")?, + native_prices, + })) + } + + /// Applies fees to every solver's bid on a fast-path order and stamps + /// the applicable fee policies. Runs after the autopilot picks up the + /// placed order and computes the policies via `ProtocolFees::apply`. + /// + /// Each bid's own raw `executed_sell`/`executed_buy` is adjusted by the + /// same volume factors so the recorded amounts stay consistent across + /// the whole competition — not just the winning row. + pub async fn record_fast_path_fees( + &self, + auction_id: database::auction::AuctionId, + order_uid: domain::OrderUid, + order_kind: model::order::OrderKind, + volume_factors: &[configs::fee_factor::FeeFactor], + fee_policies: &[domain::fee::Policy], + ) -> anyhow::Result<()> { + let _timer = Metrics::get() + .database_queries + .with_label_values(&["record_fast_path_fees"]) + .start_timer(); + + let uid = ByteArray(order_uid.0); + let policy_rows: Vec<_> = fee_policies + .iter() + .map(|p| dto::fee_policy::from_domain(auction_id, order_uid, *p)) + .collect(); + + let mut tx = self.postgres.pool.begin().await.context("begin")?; + let bids = database::fast_path::fast_path_bids(tx.deref_mut(), auction_id, uid) + .await + .context("fetch fast-path bids")?; + let adjusted_bids: Vec<_> = bids + .into_iter() + .map(|bid| { + let raw_sell = big_decimal_to_u256(&bid.executed_sell) + .context("bid executed_sell not a U256")?; + let raw_buy = big_decimal_to_u256(&bid.executed_buy) + .context("bid executed_buy not a U256")?; + let (adjusted_sell, adjusted_buy) = shared::fee::apply_volume_fees( + raw_sell, + raw_buy, + order_kind, + volume_factors.iter().copied(), + ); + anyhow::Ok(database::fast_path::FastPathBid { + solution_uid: bid.solution_uid, + executed_sell: u256_to_big_decimal(&adjusted_sell), + executed_buy: u256_to_big_decimal(&adjusted_buy), + }) + }) + .collect::>()?; + database::fast_path::apply_fees_to_fast_path_bids( + tx.deref_mut(), + auction_id, + uid, + &adjusted_bids, + ) + .await + .context("apply_fees_to_fast_path_bids")?; + database::fee_policies::insert_batch(tx.deref_mut(), policy_rows) + .await + .context("insert fast-path fee policies")?; + tx.commit().await.context("commit")?; + Ok(()) + } +} + +/// The data the autopilot needs to settle a fast-path order out of competition. +pub struct FastPathOrder { + /// The order in the raw API model form. Callers pass this to + /// `ProtocolFees::apply` and can then convert it to `domain::Order` via + /// `boundary::order::to_domain` once the resulting policies are known. + pub model_order: model::order::Order, + pub auction_id: database::auction::AuctionId, + pub solution_id: u64, + pub solution_uid: usize, + pub solver: eth::Address, + /// The `proposed_trade_executions` amounts as stored at quote time + /// (pre-fee-adjustment; identical to the quote's amounts since nothing + /// rewrites them between quote time and fast-path handling). Feed these + /// to `apply_volume_fees` alongside the Volume-type policies to obtain + /// the actual limit prices to settle at. + pub raw_sell: eth::U256, + pub raw_buy: eth::U256, + /// Native prices (token → normalized price) from the quote's auction. + pub native_prices: HashMap, } #[derive(prometheus_metric_storage::MetricStorage)] diff --git a/crates/autopilot/src/infra/solvers/dto/settle.rs b/crates/autopilot/src/infra/solvers/dto/settle.rs index 385ad68212..b9fa65a70d 100644 --- a/crates/autopilot/src/infra/solvers/dto/settle.rs +++ b/crates/autopilot/src/infra/solvers/dto/settle.rs @@ -1,6 +1,10 @@ use { + crate::infra::persistence::dto::order::Order, + alloy::primitives::{Address, U256}, + number::serialization::HexOrDecimalU256, serde::Serialize, serde_with::{serde_as, skip_serializing_none}, + std::collections::HashMap, }; #[serde_as] @@ -15,4 +19,30 @@ pub struct Request { /// Auction ID in which the specified solution ID is competing. #[serde_as(as = "serde_with::DisplayFromStr")] pub auction_id: i64, + /// Fast-path (out-of-competition) inputs. Present only when settling a + /// cached quote solution against the real signed order. + pub fast_path: Option, +} + +#[serde_as] +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FastPath { + /// The real signed order the cached solution is re-encoded against. + pub order: Order, + /// The sell/buy amounts the order must fill at exactly. + pub limit_prices: LimitPrices, + /// Native prices (wei per 10**18) for the order's tokens. + #[serde_as(as = "HashMap<_, HexOrDecimalU256>")] + pub native_prices: HashMap, +} + +#[serde_as] +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LimitPrices { + #[serde_as(as = "HexOrDecimalU256")] + pub sell: U256, + #[serde_as(as = "HexOrDecimalU256")] + pub buy: U256, } diff --git a/crates/autopilot/src/run.rs b/crates/autopilot/src/run.rs index 89ec31308e..a186e62726 100644 --- a/crates/autopilot/src/run.rs +++ b/crates/autopilot/src/run.rs @@ -491,6 +491,20 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController) None => None, }; + let protocol_fees = Arc::new(domain::ProtocolFees::new( + &config.fee_policies, + config + .shared + .volume_fee_bucket_overrides + .iter() + .map(Into::into) + .collect(), + config.shared.enable_sell_equals_buy_volume_fee, + *eth.contracts().weth().address(), + )); + let surplus_capturing_jit_order_owners = + Arc::new(config.surplus_capturing_jit_order_owners.clone()); + let solvable_orders_cache = SolvableOrdersCache::new( config.min_order_validity_period, persistence.clone(), @@ -499,19 +513,9 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController) deny_listed_tokens.clone(), competition_native_price_updater.clone(), *eth.contracts().weth().address(), - domain::ProtocolFees::new( - &config.fee_policies, - config - .shared - .volume_fee_bucket_overrides - .iter() - .map(Into::into) - .collect(), - config.shared.enable_sell_equals_buy_volume_fee, - *eth.contracts().weth().address(), - ), + protocol_fees.clone(), penalty_cap_calculator, - config.surplus_capturing_jit_order_owners, + surplus_capturing_jit_order_owners.clone(), config.native_price_timeout, *eth.contracts().settlement().address(), config.disable_order_balance_filter, @@ -662,6 +666,8 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController) }, awaiter, new_orders_receiver, + protocol_fees, + surplus_capturing_jit_order_owners, ); run.run_forever(shutdown_controller).await; diff --git a/crates/autopilot/src/run_loop.rs b/crates/autopilot/src/run_loop.rs index 2c7b688afb..9ab9707e81 100644 --- a/crates/autopilot/src/run_loop.rs +++ b/crates/autopilot/src/run_loop.rs @@ -1,7 +1,11 @@ +pub mod fast_path; pub mod settle_call_coordinator; use { - self::settle_call_coordinator::{SettleCallCoordinator, SettleError}, + self::{ + fast_path::FastPathHandler, + settle_call_coordinator::{SettleCallCoordinator, SettleError}, + }, crate::{ domain::{ self, @@ -170,8 +174,10 @@ pub struct RunLoop { /// Drivers that do NOT support delta auctions drivers: Vec>, /// Sends `/settle` calls to drivers and waits for the resulting - /// transaction to be mined. + /// transaction to be mined. Shared with the fast-path handler. settle_coordinator: Arc, + /// Handles fast-path orders on the side. + fast_path: Arc, } impl RunLoop { @@ -186,6 +192,8 @@ impl RunLoop { probes: Probes, maintenance: MaintenanceSync, new_orders_listener: mpsc::UnboundedReceiver, + protocol_fees: Arc, + surplus_capturing_jit_order_owners: Arc>, ) -> Arc { let max_winners = config.max_winners_per_auction.get(); let weth = eth.contracts().wrapped_native_token(); @@ -200,6 +208,16 @@ impl RunLoop { config.max_settlement_transaction_wait, )); + let fast_path = FastPathHandler::new( + eth.clone(), + persistence.clone(), + drivers.clone(), + protocol_fees, + surplus_capturing_jit_order_owners, + settle_coordinator.clone(), + config.submission_deadline, + ); + let self_ = Arc::new(Self { delta_state: std::sync::Mutex::new(DeltaState::new( config.auction_delta_checkpoint_interval, @@ -215,17 +233,34 @@ impl RunLoop { wake_notify: wake_runloop, drivers, settle_coordinator, + fast_path, }); Self::spawn_order_listener(self_.clone(), new_orders_listener); self_ } - /// Spawns a background task that listens to the creation of new orders - /// and wakes the run loop for each incoming order. + /// Spawns a background task that listens to the creation of new orders and + /// notifies the runloop to kick it off if necessary and initiates the + /// fast path handling if an order needs it. fn spawn_order_listener(self: Arc, mut receiver: mpsc::UnboundedReceiver) { tokio::spawn(async move { - while let Some(_order_uid) = receiver.next().await { + while let Some(order_uid) = receiver.next().await { self.wake_notify.notify_one(); + let persistence = self.persistence.clone(); + let fast_path = self.fast_path.clone(); + // immediately spawn separate task to never delay processing + // fast path orders + tokio::spawn( + async move { + match persistence.fast_path_order(order_uid).await { + // not a fast path order -> do nothing + Ok(None) => {} + Err(err) => tracing::error!(?err, "failed to look up fast path order"), + Ok(Some(order)) => fast_path.handle(order).await, + }; + } + .instrument(tracing::info_span!("fast_path", ?order_uid)), + ); } }); } @@ -518,6 +553,7 @@ impl RunLoop { solution_id, submission_deadline_latest_block: block_deadline, auction_id, + fast_path: None, }; match self_ diff --git a/crates/autopilot/src/run_loop/fast_path.rs b/crates/autopilot/src/run_loop/fast_path.rs new file mode 100644 index 0000000000..1d254e12e4 --- /dev/null +++ b/crates/autopilot/src/run_loop/fast_path.rs @@ -0,0 +1,220 @@ +//! Handles a fast-path order the moment it lands: computes the applicable +//! fee policies, adjusts the recorded bid amounts, and hands the resulting +//! `/settle` request to the shared [`SettleCallCoordinator`]. +//! +//! This module owns everything specific to fast-path handling that used to +//! live inline in [`crate::run_loop::RunLoop`]. + +use { + super::settle_call_coordinator::SettleCallCoordinator, + crate::{ + boundary, + domain, + infra::{self, persistence::FastPathOrder, solvers::dto::settle}, + }, + alloy::primitives::{Address, U256}, + std::sync::Arc, + tracing::instrument, +}; + +pub struct FastPathHandler { + eth: infra::Ethereum, + persistence: infra::Persistence, + drivers: Vec>, + protocol_fees: Arc, + surplus_capturing_jit_order_owners: Arc>, + settle_coordinator: Arc, + submission_deadline: u64, +} + +impl FastPathHandler { + pub fn new( + eth: infra::Ethereum, + persistence: infra::Persistence, + drivers: Vec>, + protocol_fees: Arc, + surplus_capturing_jit_order_owners: Arc>, + settle_coordinator: Arc, + submission_deadline: u64, + ) -> Arc { + Arc::new(Self { + eth, + persistence, + drivers, + protocol_fees, + surplus_capturing_jit_order_owners, + settle_coordinator, + submission_deadline, + }) + } + + /// Handles a fast-path order. Picks a final submission deadline in the + /// exclusivity period and instructs the winning solver to settle + /// directly and outside the regular auction. + #[instrument(skip_all)] + pub async fn handle(&self, fast_path_data: FastPathOrder) { + let Some(winner) = self + .drivers + .iter() + .find(|driver| driver.submission_address == fast_path_data.solver) + else { + tracing::error!( + solver = ?fast_path_data.solver, + "winning driver is currently not configured" + ); + return; + }; + + let AppliedFees { + policies, + quote, + limit_sell, + limit_buy, + } = match self.compute_and_persist_fees(&fast_path_data).await { + Ok(fees) => fees, + Err(err) => { + tracing::error!(?err, "failed to record fast-path fee policies"); + return; + } + }; + + let domain_order = + boundary::order::to_domain(&fast_path_data.model_order, policies, Some(quote), None); + + // todo: currently uses the regular auction's submission deadline but + // some smarter logic that takes block intervals, the progress + // of the current auction, and the order's valid_from into + // account should be implemented in the future + let deadline = self.eth.current_block().borrow().number + self.submission_deadline; + + let request = settle::Request { + auction_id: fast_path_data.auction_id, + solution_id: fast_path_data.solution_id, + submission_deadline_latest_block: deadline, + fast_path: Some(settle::FastPath { + order: infra::persistence::dto::order::from_domain(&domain_order), + limit_prices: settle::LimitPrices { + sell: limit_sell, + buy: limit_buy, + }, + native_prices: fast_path_data.native_prices.clone(), + }), + }; + + let res = self + .settle_coordinator + .settle( + winner, + winner.submission_address, + fast_path_data.solution_uid, + request, + ) + .await; + Metrics::fast_path_finished(&winner.name, res.is_ok()); + match res { + Ok(tx) => tracing::info!(?tx, "settled order"), + Err(err) => tracing::debug!(?err, "failed to settle order"), + }; + } + + /// Computes the fee policies the order would receive in a regular + /// auction and rewrites every bid on the order's + /// `proposed_trade_executions` row so its + /// `executed_sell`/`executed_buy` reflect fees that will actually be + /// captured at settlement. + /// + /// For fast-path we can only *apply* Volume-type policies to the quoted + /// amounts — Surplus and PriceImprovement need an execution-vs-quote + /// comparison that doesn't exist here — but Surplus / PriceImprovement + /// policies are still recorded so downstream accounting reflects reality + /// if they ever become applicable. + async fn compute_and_persist_fees( + &self, + fast_path_data: &FastPathOrder, + ) -> anyhow::Result { + let order_uid: domain::OrderUid = fast_path_data.model_order.metadata.uid.into(); + // `raw_sell`/`raw_buy` are the placeholder `proposed_trade_executions` + // amounts written at quote time; they equal the quote's own amounts + // because nothing rewrites them between then and now. + let quote = domain::Quote { + order_uid, + sell_amount: fast_path_data.raw_sell.into(), + buy_amount: fast_path_data.raw_buy.into(), + // The synthetic competition doesn't carry a network fee — the + // solver's quoted amounts already include everything the user + // will pay. Represent that as a zero fee here. + fee: U256::ZERO.into(), + solver: fast_path_data.solver.0.into(), + }; + let policies = self.protocol_fees.apply( + &fast_path_data.model_order, + Some("e), + &self.surplus_capturing_jit_order_owners, + ); + let volume_factors: Vec<_> = policies + .iter() + .filter_map(|p| match p { + domain::fee::Policy::Volume { factor } => Some(*factor), + _ => None, + }) + .collect(); + let (limit_sell, limit_buy) = shared::fee::apply_volume_fees( + fast_path_data.raw_sell, + fast_path_data.raw_buy, + fast_path_data.model_order.data.kind, + volume_factors.iter().copied(), + ); + self.persistence + .record_fast_path_fees( + fast_path_data.auction_id, + order_uid, + fast_path_data.model_order.data.kind, + &volume_factors, + &policies, + ) + .await?; + Ok(AppliedFees { + policies, + quote, + limit_sell, + limit_buy, + }) + } +} + +/// Output of the fee-policy computation and bid-adjustment step of the +/// fast-path handler. +struct AppliedFees { + /// Every policy the order would incur in a regular auction — persisted + /// verbatim. + policies: Vec, + /// The quote implied by the placeholder trade execution amounts; + /// forwarded to the driver alongside the placed order. + quote: domain::Quote, + /// Quoted sell amount after all Volume-type policies are applied. + limit_sell: U256, + /// Quoted buy amount after all Volume-type policies are applied. + limit_buy: U256, +} + +#[derive(prometheus_metric_storage::MetricStorage)] +#[metric(subsystem = "runloop")] +struct Metrics { + /// Tracks the outcome of fast-path settlements. + #[metric(labels("driver", "result"))] + fast_path_executions: prometheus::IntCounterVec, +} + +impl Metrics { + fn get() -> &'static Self { + Metrics::instance(observe::metrics::get_storage_registry()).unwrap() + } + + fn fast_path_finished(solver: &str, success: bool) { + let result = if success { "success" } else { "failure" }; + Self::get() + .fast_path_executions + .with_label_values(&[solver, result]) + .inc(); + } +} diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index e6762b399e..b8e0fa2e7d 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -137,9 +137,9 @@ pub struct SolvableOrdersCache { cache: Mutex>, native_price_estimator: Arc, weth: Address, - protocol_fees: domain::ProtocolFees, + protocol_fees: Arc, penalty_cap_calculator: Option, - surplus_capturing_jit_order_owners: Vec
, + surplus_capturing_jit_order_owners: Arc>, native_price_timeout: Duration, settlement_contract: Address, disable_order_balance_filter: bool, @@ -163,9 +163,9 @@ impl SolvableOrdersCache { deny_listed_tokens: DenyListedTokens, native_price_estimator: Arc, weth: Address, - protocol_fees: domain::ProtocolFees, + protocol_fees: Arc, penalty_cap_calculator: Option, - surplus_capturing_jit_order_owners: Vec
, + surplus_capturing_jit_order_owners: Arc>, native_price_timeout: Duration, settlement_contract: Address, disable_order_balance_filter: bool, diff --git a/crates/database/src/fast_path.rs b/crates/database/src/fast_path.rs index 1029c5726d..1861ecc5bf 100644 --- a/crates/database/src/fast_path.rs +++ b/crates/database/src/fast_path.rs @@ -1,16 +1,121 @@ //! Database queries for the fast-path settlement feature. //! //! Fast-path orders reuse a quote's synthetic solver competition as the -//! actual settlement. This module owns the promotion step that patches -//! the placeholder rows written at quote time to reference the real -//! `order_uid` ([`finalize_quote_competition`]). +//! actual settlement. This module owns: +//! +//! - the query that recovers everything the autopilot needs to fire the +//! `/settle` call once a fast-path order is placed +//! ([`single_fast_path_order`]), +//! - the promotion step that patches the placeholder rows written at quote time +//! to reference the real `order_uid` ([`finalize_quote_competition`]), +//! - fetching every bid on the fast-path order ([`fast_path_bids`]) and the +//! bulk update that stamps fee-adjusted `executed_sell`/`executed_buy` on +//! each of them ([`apply_fees_to_fast_path_bids`]). use { - crate::{OrderUid, PgTransaction, auction::AuctionId}, + crate::{ + Address, + AppId, + OrderUid, + PgTransaction, + auction::AuctionId, + orders::{ + BuyTokenDestination, + OrderClass, + OrderKind, + RawInteraction, + SellTokenSource, + SigningScheme, + }, + }, + sqlx::{ + PgConnection, + QueryBuilder, + types::{ + BigDecimal, + chrono::{DateTime, Utc}, + }, + }, std::ops::DerefMut, tracing::instrument, }; +/// The columns needed to re-encode a fast-path settlement — the placed order +/// joined with its winning solution and recorded fill. Only what the driver +/// needs is selected; order metadata and quote data are left out. +#[derive(Debug, sqlx::FromRow)] +pub struct FastPathOrder { + pub uid: OrderUid, + pub owner: Address, + pub creation_timestamp: DateTime, + pub sell_token: Address, + pub buy_token: Address, + pub sell_amount: BigDecimal, + pub buy_amount: BigDecimal, + pub valid_to: i64, + pub app_data: AppId, + pub kind: OrderKind, + pub partially_fillable: bool, + pub signature: Vec, + pub receiver: Option
, + pub signing_scheme: SigningScheme, + pub sell_token_balance: SellTokenSource, + pub buy_token_balance: BuyTokenDestination, + /// The order's class (Market / Limit / Liquidity). Loaded here so + /// `ProtocolFees::apply` can gate the protocol Volume policy on + /// `OrderClass::Limit`. + pub class: OrderClass, + pub pre_interactions: Vec, + pub post_interactions: Vec, + /// Contents of the order's `app_data` document (from the `app_data` + /// table). `None` when the full document was never uploaded. + pub full_app_data: Option>, + pub auction_id: AuctionId, + pub solution_id: BigDecimal, + pub solution_uid: i64, + pub solver: Address, + pub executed_sell: BigDecimal, + pub executed_buy: BigDecimal, + /// The quote auction's native prices (token, normalized price). + pub price_tokens: Vec
, + pub price_values: Vec, +} + +/// Recovers what's needed to settle `uid` out of competition in one query, or +/// `None` when it is not a fast-path order (no persisted quote competition). +#[instrument(skip_all)] +pub async fn single_fast_path_order( + ex: &mut PgConnection, + uid: &OrderUid, +) -> Result, sqlx::Error> { + #[rustfmt::skip] + const QUERY: &str = const_format::concatcp!( + "SELECT ", + "o.uid, o.owner, o.creation_timestamp, o.sell_token, o.buy_token, ", + "o.sell_amount, o.buy_amount, o.valid_to, o.app_data, o.kind, ", + "o.partially_fillable, o.signature, o.receiver, o.signing_scheme, ", + "o.sell_token_balance, o.buy_token_balance, o.class, ", + "array(SELECT (p.target, p.value, p.data) FROM interactions p", + " WHERE p.order_uid = o.uid AND p.execution = 'pre' ORDER BY p.index) AS pre_interactions, ", + "array(SELECT (p.target, p.value, p.data) FROM interactions p", + " WHERE p.order_uid = o.uid AND p.execution = 'post' ORDER BY p.index) AS post_interactions, ", + "ad.full_app_data AS full_app_data, ", + "oq.auction_id AS auction_id, ps.id AS solution_id, ps.uid AS solution_uid, ps.solver AS solver, ", + "pte.executed_sell AS executed_sell, pte.executed_buy AS executed_buy, ", + "ca.price_tokens AS price_tokens, ca.price_values AS price_values", + " FROM orders o", + " JOIN order_quotes oq ON oq.order_uid = o.uid", + " JOIN proposed_solutions ps ON ps.auction_id = oq.auction_id AND ps.is_winner", + " JOIN proposed_trade_executions pte", + " ON pte.auction_id = ps.auction_id AND pte.solution_uid = ps.uid AND pte.order_uid = o.uid", + " JOIN competition_auctions ca ON ca.id = oq.auction_id", + " LEFT JOIN app_data ad ON ad.contract_app_data = o.app_data", + " WHERE o.uid = $1", + " LIMIT 1", + ); + sqlx::query_as(QUERY).bind(uid).fetch_optional(ex).await +} + /// 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 @@ -39,3 +144,185 @@ WHERE id = $2 .await?; Ok(()) } + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct FastPathBid { + pub solution_uid: i64, + pub executed_sell: BigDecimal, + pub executed_buy: BigDecimal, +} + +/// Returns every proposed trade execution recorded against the fast-path +/// order — one row per competing solver. Rows for JIT orders (which use +/// different `order_uid`s) are naturally excluded. +#[instrument(skip_all)] +pub async fn fast_path_bids( + ex: &mut PgConnection, + auction_id: AuctionId, + order_uid: OrderUid, +) -> Result, sqlx::Error> { + const QUERY: &str = r#" +SELECT solution_uid, executed_sell, executed_buy +FROM proposed_trade_executions +WHERE auction_id = $1 AND order_uid = $2 +"#; + sqlx::query_as(QUERY) + .bind(auction_id) + .bind(order_uid) + .fetch_all(ex) + .await +} + +/// Overwrites the executed amounts on every competing solver's bid for a +/// fast-path order in a single query. Each row is matched by its own +/// `solution_uid`, so different bids can be updated to different values. +#[instrument(skip_all)] +pub async fn apply_fees_to_fast_path_bids( + ex: &mut PgConnection, + auction_id: AuctionId, + order_uid: OrderUid, + bids: &[FastPathBid], +) -> Result<(), sqlx::Error> { + if bids.is_empty() { + return Ok(()); + } + let mut query_builder = QueryBuilder::new( + "UPDATE proposed_trade_executions AS pte SET executed_sell = v.executed_sell, \ + executed_buy = v.executed_buy FROM (", + ); + query_builder.push_values(bids.iter(), |mut b, bid| { + b.push_bind(bid.solution_uid) + .push_bind(&bid.executed_sell) + .push_bind(&bid.executed_buy); + }); + query_builder.push(") AS v(solution_uid, executed_sell, executed_buy) WHERE pte.auction_id = "); + query_builder.push_bind(auction_id); + query_builder.push(" AND pte.order_uid = "); + query_builder.push_bind(order_uid); + query_builder.push(" AND pte.solution_uid = v.solution_uid"); + query_builder.build().execute(ex).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use {super::*, crate::byte_array::ByteArray, sqlx::Connection}; + + /// Seeds a bid row on `proposed_trade_executions` with the given raw + /// amounts. Bypasses the parent-table foreign keys because the tests + /// exercise the fast-path DB helpers in isolation. + async fn insert_bid( + db: &mut PgConnection, + auction_id: AuctionId, + order_uid: OrderUid, + solution_uid: i64, + executed_sell: i64, + executed_buy: i64, + ) { + sqlx::query( + "INSERT INTO proposed_trade_executions (auction_id, solution_uid, order_uid, \ + executed_sell, executed_buy) VALUES ($1, $2, $3, $4, $5)", + ) + .bind(auction_id) + .bind(solution_uid) + .bind(order_uid) + .bind(BigDecimal::from(executed_sell)) + .bind(BigDecimal::from(executed_buy)) + .execute(db) + .await + .unwrap(); + } + + fn bids_by_solution(mut bids: Vec) -> Vec<(i64, i64, i64)> { + bids.sort_by_key(|bid| bid.solution_uid); + bids.into_iter() + .map(|bid| { + use bigdecimal::ToPrimitive; + ( + bid.solution_uid, + bid.executed_sell.to_i64().unwrap(), + bid.executed_buy.to_i64().unwrap(), + ) + }) + .collect() + } + + #[tokio::test] + #[ignore] + async fn postgres_fast_path_bids_and_apply_fees_roundtrip() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + let auction_id = 1; + let order = ByteArray([0xaa; 56]); + let other_order = ByteArray([0xbb; 56]); + + // Two solvers bid on `order`; a third solver bid a different order + // in the same auction. The latter should never be touched by the + // helpers under test. + insert_bid(&mut db, auction_id, order, 0, 1_000, 900).await; + insert_bid(&mut db, auction_id, order, 1, 1_000, 950).await; + insert_bid(&mut db, auction_id, other_order, 2, 5_000, 4_000).await; + + // `fast_path_bids` returns only the bids for `order`. + let bids = fast_path_bids(&mut db, auction_id, order).await.unwrap(); + assert_eq!( + bids_by_solution(bids), + vec![(0, 1_000, 900), (1, 1_000, 950)] + ); + + // Rewrite each of `order`'s bids to a different post-fee value. + apply_fees_to_fast_path_bids( + &mut db, + auction_id, + order, + &[ + FastPathBid { + solution_uid: 0, + executed_sell: BigDecimal::from(1_000), + executed_buy: BigDecimal::from(882), + }, + FastPathBid { + solution_uid: 1, + executed_sell: BigDecimal::from(1_000), + executed_buy: BigDecimal::from(931), + }, + ], + ) + .await + .unwrap(); + + // Each bid should have received its own updated amounts… + let bids = fast_path_bids(&mut db, auction_id, order).await.unwrap(); + assert_eq!( + bids_by_solution(bids), + vec![(0, 1_000, 882), (1, 1_000, 931)] + ); + + // …and the unrelated bid on `other_order` should be untouched. + let others = fast_path_bids(&mut db, auction_id, other_order) + .await + .unwrap(); + assert_eq!(bids_by_solution(others), vec![(2, 5_000, 4_000)]); + } + + #[tokio::test] + #[ignore] + async fn postgres_apply_fees_to_fast_path_bids_empty_is_noop() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + let auction_id = 1; + let order = ByteArray([0xaa; 56]); + insert_bid(&mut db, auction_id, order, 0, 1_000, 900).await; + + apply_fees_to_fast_path_bids(&mut db, auction_id, order, &[]) + .await + .unwrap(); + + let bids = fast_path_bids(&mut db, auction_id, order).await.unwrap(); + assert_eq!(bids_by_solution(bids), vec![(0, 1_000, 900)]); + } +} diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index b3ee21f7d9..6ea8d6bf3a 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -498,7 +498,7 @@ AND cancellation_timestamp IS NULL /// This is done as sqlx does not support reading arrays of more complicated /// types than just one field. The pre_ and post_interaction's data of /// target, value and data are composed to an array of interactions later. -type RawInteraction = (Address, BigDecimal, Vec); +pub type RawInteraction = (Address, BigDecimal, Vec); /// Order with extra information from other tables. Has all the information /// needed to construct a model::Order. diff --git a/crates/driver/src/domain/competition/solution/mod.rs b/crates/driver/src/domain/competition/solution/mod.rs index d8fabb8c69..dbebead73a 100644 --- a/crates/driver/src/domain/competition/solution/mod.rs +++ b/crates/driver/src/domain/competition/solution/mod.rs @@ -98,10 +98,8 @@ fn compare_orders(order: &competition::Order, quoted: &competition::Order) -> bo && order.target() == quoted.target() } -fn recover_flashloans_and_wrappers( - order: &competition::Order, -) -> (HashMap, Vec) { - let flashloans = order +fn recover_flashloans(order: &competition::Order) -> HashMap { + order .app_data .flashloan() .map(|f| { @@ -109,8 +107,11 @@ fn recover_flashloans_and_wrappers( (order.uid, (&flashloan).into()) }) .into_iter() - .collect(); - let wrappers = order + .collect() +} + +fn recover_wrappers(order: &competition::Order) -> Vec { + order .app_data .wrappers() .iter() @@ -118,8 +119,7 @@ fn recover_flashloans_and_wrappers( address: w.address, data: w.data.clone().into(), }) - .collect(); - (flashloans, wrappers) + .collect() } impl Solution { @@ -559,7 +559,7 @@ impl Solution { limit_prices: LimitPrices, ) -> Result { let mut solution = self.clone(); - let Ok(user) = solution + let Ok(user_trade) = solution .trades .iter_mut() .filter_map(|trade| match trade { @@ -571,7 +571,7 @@ impl Solution { return Err(error::Error::FastPathTradeCount(self.user_trades().count())); }; - if !compare_orders(&order, user.order()) { + if !compare_orders(&order, user_trade.order()) { return Err(error::Error::FastPathOrderMismatch); } @@ -583,23 +583,25 @@ impl Solution { .clearing_price(order.buy.token) .ok_or(error::Error::FastPathOrderMismatch)?, }; + // todo: double check if this makes sense... let within_limit = match order.side { - order::Side::Sell => user.buy_amount(&clearing)?.0 >= limit_prices.buy, - order::Side::Buy => user.sell_amount(&clearing)?.0 <= limit_prices.sell, + order::Side::Sell => user_trade.buy_amount(&clearing)?.0 >= limit_prices.buy, + order::Side::Buy => user_trade.sell_amount(&clearing)?.0 <= limit_prices.sell, }; if !within_limit { + // todo: descriptive error message evaluating how much the price was + // off roughly return Err(error::Error::FastPathLimitNotMet); } - let (flashloans, wrappers) = recover_flashloans_and_wrappers(&order); - *user = user.with_order(order)?; - solution.flashloans = flashloans; - solution.wrappers = wrappers; + solution.flashloans = recover_flashloans(&order); + solution.wrappers = recover_wrappers(&order); + *user_trade = user_trade.with_order(order)?; // Pin the fill to the signed limit so it settles at exactly // the price the autopilot expects. - let sell = user.order().sell.token.as_erc20(self.weth); - let buy = user.order().buy.token.as_erc20(self.weth); + let sell = user_trade.order().sell.token.as_erc20(self.weth); + let buy = user_trade.order().buy.token.as_erc20(self.weth); solution.prices.insert(sell, limit_prices.buy); solution.prices.insert(buy, limit_prices.sell); Ok(solution) diff --git a/crates/driver/src/domain/competition/solution/trade.rs b/crates/driver/src/domain/competition/solution/trade.rs index 288fbf7b68..a751023e7c 100644 --- a/crates/driver/src/domain/competition/solution/trade.rs +++ b/crates/driver/src/domain/competition/solution/trade.rs @@ -169,10 +169,20 @@ impl Fulfillment { &self.order } - /// Rebuild this fulfillment for a different `order`, keeping the executed - /// amount, fee and haircut. + /// Rebuild this fulfillment for a different `order` filling it entirely. + /// Keeps the original fee and haircut. pub fn with_order(&self, order: competition::Order) -> Result { - Self::new(order, self.executed, self.fee, self.haircut_fee) + let fee = if order.solver_determines_fee() { + self.fee() + } else { + order::SellAmount::default() + }; + let executed = order::TargetAmount(match order.side { + order::Side::Sell => order.sell.amount.0, + order::Side::Buy => order.buy.amount.0, + }); + + Self::new(order, executed, fee, self.haircut_fee) } pub fn executed(&self) -> order::TargetAmount { diff --git a/crates/shared/src/db_order_conversions.rs b/crates/shared/src/db_order_conversions.rs index 3f114dc79b..9fd8e6bc5b 100644 --- a/crates/shared/src/db_order_conversions.rs +++ b/crates/shared/src/db_order_conversions.rs @@ -4,6 +4,7 @@ use { app_data::AppDataHash, bigdecimal::BigDecimal, database::{ + fast_path::FastPathOrder as FastPathOrderDb, onchain_broadcasted_orders::OnchainOrderPlacementError as DbOnchainOrderPlacementError, orders::{ BuyTokenDestination as DbBuyTokenDestination, @@ -11,6 +12,7 @@ use { FullOrder as FullOrderDb, OrderClass as DbOrderClass, OrderKind as DbOrderKind, + RawInteraction, SellTokenSource as DbSellTokenSource, SigningScheme as DbSigningScheme, }, @@ -133,6 +135,53 @@ pub fn full_order_into_model_order(order: database::orders::FullOrder) -> Result }) } +pub fn fast_path_order_into_model(order: &FastPathOrderDb) -> Result { + let full_app_data = order + .full_app_data + .as_ref() + .map(|bytes| String::from_utf8(bytes.clone())) + .transpose() + .context("full app data isn't utf-8")?; + let class = match order.class { + DbOrderClass::Market => OrderClass::Market, + DbOrderClass::Liquidity => OrderClass::Liquidity, + DbOrderClass::Limit => OrderClass::Limit, + }; + let metadata = OrderMetadata { + creation_date: order.creation_timestamp, + owner: Address::new(order.owner.0), + uid: OrderUid(order.uid.0), + full_app_data, + class, + ..Default::default() + }; + let data = OrderData { + sell_token: Address::new(order.sell_token.0), + buy_token: Address::new(order.buy_token.0), + receiver: order.receiver.map(|address| Address::new(address.0)), + sell_amount: big_decimal_to_u256(&order.sell_amount).context("sell_amount is not U256")?, + buy_amount: big_decimal_to_u256(&order.buy_amount).context("buy_amount is not U256")?, + valid_to: order.valid_to.try_into().context("valid_to is not u32")?, + app_data: AppDataHash(order.app_data.0), + fee_amount: Default::default(), + kind: order_kind_from(order.kind), + partially_fillable: order.partially_fillable, + sell_token_balance: sell_token_source_from(order.sell_token_balance), + buy_token_balance: buy_token_destination_from(order.buy_token_balance), + }; + let signature = + Signature::from_bytes(signing_scheme_from(order.signing_scheme), &order.signature)?; + Ok(Order { + metadata, + data, + signature, + interactions: Interactions { + pre: raw_interactions_into_model(&order.pre_interactions)?, + post: raw_interactions_into_model(&order.post_interactions)?, + }, + }) +} + pub fn order_quote_into_model( quote: &database::orders::Quote, status: model::order::OrderStatus, @@ -174,6 +223,12 @@ pub fn extract_interactions( ExecutionTime::Pre => &order.pre_interactions, ExecutionTime::Post => &order.post_interactions, }; + raw_interactions_into_model(interactions) +} + +pub fn raw_interactions_into_model( + interactions: &[RawInteraction], +) -> Result> { interactions .iter() .map(|interaction| { diff --git a/crates/shared/src/fee.rs b/crates/shared/src/fee.rs index 33bae13423..8e5d57cde5 100644 --- a/crates/shared/src/fee.rs +++ b/crates/shared/src/fee.rs @@ -1,8 +1,8 @@ use { crate::{arguments::TokenBucketFeeOverride, order_validation::is_same_buy_and_sell_token}, - alloy::primitives::{Address, U256}, + alloy::primitives::{Address, U256, U512, ruint::UintTryFrom}, configs::fee_factor::FeeFactor, - model::order::BUY_ETH_ADDRESS, + model::order::{BUY_ETH_ADDRESS, OrderKind}, }; /// Everything required to compute the fee amount in sell token @@ -126,6 +126,51 @@ impl VolumeFeePolicy { } } +/// Applies a single volume fee to `(sell, buy)` for the given order kind. +/// +/// - Sell orders: the buy amount is reduced by `buy * factor`. +/// - Buy orders: the sell amount is increased by `sell * factor`. +/// +/// Uses high-precision scaling so sub-BPS factors don't round to zero. +pub fn apply_volume_fee(sell: U256, buy: U256, kind: OrderKind, factor: FeeFactor) -> (U256, U256) { + let scaled_factor = U256::from(factor.to_high_precision()); + let scale = U512::from(FeeFactor::HIGH_PRECISION_SCALE); + + match kind { + OrderKind::Sell => { + let fee = U256::uint_try_from( + buy.widening_mul(scaled_factor) + .checked_div(scale) + .unwrap_or_default(), + ) + .unwrap_or(U256::MAX); + (sell, buy.saturating_sub(fee)) + } + OrderKind::Buy => { + let fee = U256::uint_try_from( + sell.widening_mul(scaled_factor) + .checked_div(scale) + .unwrap_or_default(), + ) + .unwrap_or(U256::MAX); + (sell.saturating_add(fee), buy) + } + } +} + +/// Applies a sequence of volume fee factors to `(sell, buy)` in order, +/// compounding each fee on the amount produced by the previous step. +pub fn apply_volume_fees(sell: U256, buy: U256, kind: OrderKind, factors: I) -> (U256, U256) +where + I: IntoIterator, +{ + factors + .into_iter() + .fold((sell, buy), |(sell, buy), factor| { + apply_volume_fee(sell, buy, kind, factor) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -251,4 +296,62 @@ mod tests { Some(default_fee) ); } + + fn factor(v: f64) -> FeeFactor { + FeeFactor::try_from(v).unwrap() + } + + #[test] + fn apply_volume_fee_sell_order_reduces_buy() { + let (sell, buy) = apply_volume_fee( + U256::from(1_000u64), + U256::from(1_000u64), + OrderKind::Sell, + factor(0.01), + ); + assert_eq!(sell, U256::from(1_000u64)); + assert_eq!(buy, U256::from(990u64)); + } + + #[test] + fn apply_volume_fee_buy_order_increases_sell() { + let (sell, buy) = apply_volume_fee( + U256::from(1_000u64), + U256::from(1_000u64), + OrderKind::Buy, + factor(0.01), + ); + assert_eq!(sell, U256::from(1_010u64)); + assert_eq!(buy, U256::from(1_000u64)); + } + + #[test] + fn apply_volume_fee_sub_bps_uses_high_precision() { + // 0.3 BPS = 0.00003 must not round to zero. + let (_, buy) = apply_volume_fee( + U256::from(10u64), + U256::from(1_000_000u64), + OrderKind::Sell, + factor(0.00003), + ); + assert_eq!(buy, U256::from(999_970u64)); + } + + #[test] + fn apply_volume_fees_compounds_in_order() { + let (mid_sell, mid_buy) = apply_volume_fee( + U256::from(1_000u64), + U256::from(1_000u64), + OrderKind::Sell, + factor(0.01), + ); + let expected = apply_volume_fee(mid_sell, mid_buy, OrderKind::Sell, factor(0.02)); + let actual = apply_volume_fees( + U256::from(1_000u64), + U256::from(1_000u64), + OrderKind::Sell, + [factor(0.01), factor(0.02)], + ); + assert_eq!(expected, actual); + } }