diff --git a/crates/e2e/src/setup/colocation.rs b/crates/e2e/src/setup/colocation.rs index fc63d1c8fd..76c78188de 100644 --- a/crates/e2e/src/setup/colocation.rs +++ b/crates/e2e/src/setup/colocation.rs @@ -182,6 +182,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..5efb27231c --- /dev/null +++ b/crates/e2e/tests/e2e/fast_path_quote_promotion.rs @@ -0,0 +1,456 @@ +use { + crate::ethflow::ExtendedEthFlowOrder, + app_data::AppDataHash, + configs::test_util::TestDefault, + 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, time::Duration}, +}; + +#[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; + + let exclusivity = Duration::from_secs(100); + let orderbook_config = configs::orderbook::Configuration { + order_validation: configs::orderbook::order_validation::OrderValidationConfig { + min_fast_path_exclusivity: Some(exclusivity), + ..Default::default() + }, + ..configs::orderbook::Configuration::test_default() + }; + services + .start_protocol_with_args( + configs::autopilot::Configuration::test("test_solver", solver.address()), + orderbook_config, + solver, + ) + .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; + + let exclusivity = Duration::from_secs(100); + let orderbook_config = configs::orderbook::Configuration { + order_validation: configs::orderbook::order_validation::OrderValidationConfig { + min_fast_path_exclusivity: Some(exclusivity), + ..Default::default() + }, + ..configs::orderbook::Configuration::test_default() + }; + services + .start_protocol_with_args( + configs::autopilot::Configuration::test("test_solver", solver.address()), + orderbook_config, + 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/fast_path_settle.rs b/crates/e2e/tests/e2e/fast_path_settle.rs new file mode 100644 index 0000000000..505c5be763 --- /dev/null +++ b/crates/e2e/tests/e2e/fast_path_settle.rs @@ -0,0 +1,519 @@ +use { + ::alloy::primitives::{Address, U256}, + configs::{ + autopilot::{ + Configuration as AutopilotConfiguration, + fee_policy::{ + FeePoliciesConfig, + FeePolicy as ConfigFeePolicy, + FeePolicyKind as ConfigFeePolicyKind, + FeePolicyOrderClass as ConfigFeePolicyOrderClass, + }, + solver::Solver, + }, + test_util::TestDefault, + }, + database::byte_array::ByteArray, + e2e::setup::*, + ethrpc::alloy::CallBuilderExt, + model::{ + fee_policy::FeePolicy as TradeFeePolicy, + order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus}, + quote::{OrderQuoteRequest, OrderQuoteSide, SellAmount}, + signature::EcdsaSigningScheme, + }, + number::{nonzero::NonZeroU256, units::EthUnit}, + serde_json::json, + shared::web3::Web3, + std::{ops::DerefMut, time::Duration}, +}; + +#[tokio::test] +#[ignore] +async fn local_node_fast_path_settle() { + run_test(fast_path_settle).await; +} + +#[tokio::test] +#[ignore] +async fn local_node_fast_path_regular_auction_fallback() { + run_test(fast_path_regular_auction_fallback).await; +} + +#[tokio::test] +#[ignore] +async fn local_node_fast_path_volume_fees_captured() { + run_test(fast_path_volume_fees_captured).await; +} + +async fn fast_path_settle(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; + + let sell_amount = 1u64.eth(); + onchain + .contracts() + .weth + .approve(onchain.contracts().allowance, sell_amount) + .from(trader.address()) + .send_and_watch() + .await + .unwrap(); + onchain + .contracts() + .weth + .deposit() + .from(trader.address()) + .value(sell_amount) + .send_and_watch() + .await + .unwrap(); + + tracing::info!("Starting services."); + let services = Services::new(&onchain).await; + // A long fast-path exclusivity so only the fast path can settle the order + // within the test window. + let exclusivity = Duration::from_secs(300); + let orderbook_config = configs::orderbook::Configuration { + order_validation: configs::orderbook::order_validation::OrderValidationConfig { + min_fast_path_exclusivity: Some(exclusivity), + ..Default::default() + }, + ..configs::orderbook::Configuration::test_default() + }; + services + .start_protocol_with_args( + configs::autopilot::Configuration::test("test_solver", solver.address()), + orderbook_config, + solver, + ) + .await; + + let app_data = r#"{"metadata":{"enableFastPath":true}}"#.to_string(); + + tracing::info!("Quoting with enableFastPath."); + 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(sell_amount).unwrap(), + }, + }, + app_data: OrderCreationAppData::Full { + full: app_data.clone(), + }, + ..Default::default() + }; + let quote = services.submit_quote("e_request).await.unwrap(); + let quote_id = quote.id.expect("fast-path quote should carry an id"); + + tracing::info!("Placing the fast-path order."); + let order = OrderCreation { + quote_id: Some(quote_id), + sell_token: *onchain.contracts().weth.address(), + sell_amount, + buy_token: *token.address(), + buy_amount: quote.quote.buy_amount, + valid_to: model::time::now_in_epoch_seconds() + 3600, + kind: OrderKind::Sell, + app_data: OrderCreationAppData::Full { full: app_data }, + ..Default::default() + } + .sign( + EcdsaSigningScheme::Eip712, + &onchain.contracts().domain_separator, + &trader.signer, + ); + let uid = services.create_order(&order).await.unwrap(); + + let valid_from = { + let mut db = services.db().acquire().await.unwrap(); + sqlx::query_scalar::<_, Option>("SELECT valid_from FROM orders WHERE uid = $1") + .bind(ByteArray(uid.0)) + .fetch_one(db.deref_mut()) + .await + .unwrap() + .expect("fast-path order has a valid_from") as u32 + }; + let expected = model::time::now_in_epoch_seconds() + exclusivity.as_secs() as u32; + assert!( + valid_from.abs_diff(expected) <= 2, + "valid_from {valid_from} should be ~{expected} (now + exclusivity)" + ); + + tracing::info!("Waiting for the fast-path settlement."); + wait_for_condition(TIMEOUT, || async { + services + .get_order(&uid) + .await + .is_ok_and(|order| order.metadata.status == OrderStatus::Fulfilled) + }) + .await + .unwrap(); + + assert!( + model::time::now_in_epoch_seconds() < valid_from, + "order settled after the exclusivity window; can't attribute it to the fast path" + ); + // The order only appears in its own quote (fast-path) auction, never a + // regular batch auction during the exclusive window. + let regular_auctions: Vec = { + let mut db = services.db().acquire().await.unwrap(); + let quote_auction: Option = + sqlx::query_scalar("SELECT auction_id FROM order_quotes WHERE order_uid = $1") + .bind(ByteArray(uid.0)) + .fetch_one(db.deref_mut()) + .await + .unwrap(); + let quote_auction = quote_auction.expect("fast-path order has a quote auction"); + sqlx::query_scalar( + "SELECT id FROM competition_auctions WHERE order_uids @> ARRAY[$1::bytea] AND id != $2", + ) + .bind(ByteArray(uid.0)) + .bind(quote_auction) + .fetch_all(db.deref_mut()) + .await + .unwrap() + }; + assert!( + regular_auctions.is_empty(), + "fast-path order must not appear in a regular auction: {regular_auctions:?}" + ); +} + +/// When a fast-path order's exclusive window elapses without the fast path +/// settling it, the regular auction settles it once `valid_from` passes. The +/// order is quoted without `enableFastPath`, so no fast-path solution is cached +/// and the settler has nothing to submit — standing in for a solver that held +/// the exclusive window but never settled. +async fn fast_path_regular_auction_fallback(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; + + let sell_amount = 1u64.eth(); + onchain + .contracts() + .weth + .approve(onchain.contracts().allowance, sell_amount) + .from(trader.address()) + .send_and_watch() + .await + .unwrap(); + onchain + .contracts() + .weth + .deposit() + .from(trader.address()) + .value(sell_amount) + .send_and_watch() + .await + .unwrap(); + + tracing::info!("Starting services."); + let services = Services::new(&onchain).await; + // A short window so the regular auction picks the order up soon after it + // elapses, within the test timeout. + let exclusivity = Duration::from_secs(5); + let orderbook_config = configs::orderbook::Configuration { + order_validation: configs::orderbook::order_validation::OrderValidationConfig { + min_fast_path_exclusivity: Some(exclusivity), + ..Default::default() + }, + ..configs::orderbook::Configuration::test_default() + }; + services + .start_protocol_with_args( + configs::autopilot::Configuration::test("test_solver", solver.address()), + orderbook_config, + solver, + ) + .await; + + // A plain quote leaves no cached fast-path solution. + tracing::info!("Quoting without enableFastPath."); + 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(sell_amount).unwrap(), + }, + }, + ..Default::default() + }; + let quote = services.submit_quote("e_request).await.unwrap(); + + // The order still requests the fast path, so the orderbook holds it out of + // the auction until `valid_from`. + tracing::info!("Placing the fast-path order."); + let app_data = r#"{"metadata":{"enableFastPath":true}}"#.to_string(); + let order = OrderCreation { + quote_id: quote.id, + sell_token: *onchain.contracts().weth.address(), + sell_amount, + buy_token: *token.address(), + buy_amount: quote.quote.buy_amount, + valid_to: model::time::now_in_epoch_seconds() + 3600, + kind: OrderKind::Sell, + app_data: OrderCreationAppData::Full { full: app_data }, + ..Default::default() + } + .sign( + EcdsaSigningScheme::Eip712, + &onchain.contracts().domain_separator, + &trader.signer, + ); + let uid = services.create_order(&order).await.unwrap(); + + // Held out: not settled early by the fast path. + assert_eq!( + services.get_order(&uid).await.unwrap().metadata.status, + OrderStatus::Open + ); + let valid_from = { + let mut db = services.db().acquire().await.unwrap(); + sqlx::query_scalar::<_, Option>("SELECT valid_from FROM orders WHERE uid = $1") + .bind(ByteArray(uid.0)) + .fetch_one(db.deref_mut()) + .await + .unwrap() + .expect("fast-path order has a valid_from") as u32 + }; + assert!( + valid_from > model::time::now_in_epoch_seconds(), + "valid_from {valid_from} should be in the future (order held out)" + ); + + tracing::info!("Waiting for the regular-auction settlement."); + wait_for_condition(TIMEOUT, || async { + onchain.mint_block().await; + services + .get_order(&uid) + .await + .is_ok_and(|order| order.metadata.status == OrderStatus::Fulfilled) + }) + .await + .unwrap(); + + // Settled only after the exclusive window elapsed. + assert!( + model::time::now_in_epoch_seconds() >= valid_from, + "order settled before the exclusivity window elapsed" + ); + // A plain quote writes no competition auction, so any auction carrying the + // order is a regular one — proof it settled via the regular auction. + let regular_auctions: Vec = { + let mut db = services.db().acquire().await.unwrap(); + sqlx::query_scalar( + "SELECT id FROM competition_auctions WHERE order_uids @> ARRAY[$1::bytea]", + ) + .bind(ByteArray(uid.0)) + .fetch_all(db.deref_mut()) + .await + .unwrap() + }; + assert!( + !regular_auctions.is_empty(), + "fast-path order should have settled via a regular auction" + ); +} + +/// Configures a protocol volume fee via the autopilot config and a partner +/// volume fee via app-data, and verifies that both are recorded against the +/// fast-path order and that every bid's `executed_sell`/`executed_buy` reflects +/// the compounded fee reduction. +async fn fast_path_volume_fees_captured(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; + + let sell_amount = 1u64.eth(); + onchain + .contracts() + .weth + .approve(onchain.contracts().allowance, sell_amount) + .from(trader.address()) + .send_and_watch() + .await + .unwrap(); + onchain + .contracts() + .weth + .deposit() + .from(trader.address()) + .value(sell_amount) + .send_and_watch() + .await + .unwrap(); + + tracing::info!("Starting services."); + let services = Services::new(&onchain).await; + + // 1% protocol volume fee applied to any order class. + let protocol_volume_factor: f64 = 0.01; + // 2% partner volume fee (200 bps). + let partner_volume_bps: u64 = 200; + let partner_recipient = Address::repeat_byte(0xb0); + let exclusivity = Duration::from_secs(300); + + let orderbook_config = configs::orderbook::Configuration { + order_validation: configs::orderbook::order_validation::OrderValidationConfig { + min_fast_path_exclusivity: Some(exclusivity), + ..Default::default() + }, + ..configs::orderbook::Configuration::test_default() + }; + let autopilot_config = AutopilotConfiguration { + drivers: vec![Solver::test("test_solver", solver.address())], + fee_policies: FeePoliciesConfig { + policies: vec![ConfigFeePolicy { + kind: ConfigFeePolicyKind::Volume { + factor: protocol_volume_factor.try_into().unwrap(), + }, + order_class: ConfigFeePolicyOrderClass::Any, + }], + // Room for the partner factor (2%). + max_partner_fee: 0.05.try_into().unwrap(), + ..Default::default() + }, + ..AutopilotConfiguration::test_no_drivers() + }; + services + .start_protocol_with_args(autopilot_config, orderbook_config, solver) + .await; + + let app_data = json!({ + "version": "1.1.0", + "metadata": { + "enableFastPath": true, + "partnerFee": { + "bps": partner_volume_bps, + "recipient": partner_recipient, + } + } + }) + .to_string(); + + tracing::info!("Quoting with enableFastPath and a partner fee."); + 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(sell_amount).unwrap(), + }, + }, + app_data: OrderCreationAppData::Full { + full: app_data.clone(), + }, + ..Default::default() + }; + let quote = services.submit_quote("e_request).await.unwrap(); + let quote_id = quote.id.expect("fast-path quote should carry an id"); + + // Sign a buy amount comfortably below the quote so the fee-reduced + // `executed_buy` still clears the on-chain limit-price check. + let signed_buy = quote.quote.buy_amount * U256::from(90u64) / U256::from(100u64); + tracing::info!("Placing the fast-path order."); + let order = OrderCreation { + quote_id: Some(quote_id), + sell_token: *onchain.contracts().weth.address(), + sell_amount, + buy_token: *token.address(), + buy_amount: signed_buy, + valid_to: model::time::now_in_epoch_seconds() + 3600, + kind: OrderKind::Sell, + app_data: OrderCreationAppData::Full { full: app_data }, + ..Default::default() + } + .sign( + EcdsaSigningScheme::Eip712, + &onchain.contracts().domain_separator, + &trader.signer, + ); + let uid = services.create_order(&order).await.unwrap(); + + tracing::info!("Waiting for the fast-path settlement."); + wait_for_condition(TIMEOUT, || async { + services + .get_order(&uid) + .await + .is_ok_and(|order| order.metadata.status == OrderStatus::Fulfilled) + }) + .await + .unwrap(); + + // The /trades API rebuilds the fees from `order_execution` (written by + // the settlement observer once the tx is mined). The observer runs + // slightly after the trade event, so wait for both fee entries to appear. + tracing::info!("Waiting for /trades to report both volume fees."); + wait_for_condition(TIMEOUT, || async { + services.get_trades(&uid).await.is_ok_and(|trades| { + trades + .first() + .is_some_and(|t| t.executed_protocol_fees.len() == 2) + }) + }) + .await + .unwrap(); + let trades = services.get_trades(&uid).await.unwrap(); + assert_eq!( + trades.len(), + 1, + "expected one trade for the fast-path order" + ); + let trade = &trades[0]; + assert_eq!( + trade.executed_protocol_fees.len(), + 2, + "expected two Volume fee entries (protocol + partner), got {:?}", + trade.executed_protocol_fees + ); + + let buy_token = *token.address(); + let expected_partner_factor = partner_volume_bps as f64 / 10_000.0; + let expected_factors = [protocol_volume_factor, expected_partner_factor]; + for (fee, expected_factor) in trade.executed_protocol_fees.iter().zip(expected_factors) { + assert_eq!( + fee.token, buy_token, + "fees on a sell order are taken from the buy token" + ); + assert!(!fee.amount.is_zero(), "fee amount should be positive"); + match fee.policy { + TradeFeePolicy::Volume { factor } => assert!( + (factor - expected_factor).abs() < 1e-9, + "unexpected volume factor {factor} (expected {expected_factor})" + ), + ref other => panic!("fast-path fees should all be Volume, got {other:?}"), + } + } + + // The on-chain buy_amount should be strictly smaller than what the API + // quoted — a sanity check that fees actually shrunk the fill, and not + // just that the fee rows exist. + let executed_buy = number::conversions::big_uint_to_u256(&trade.buy_amount) + .expect("trade buy amount fits in U256"); + assert!( + executed_buy < quote.quote.buy_amount, + "executed buy {executed_buy} should be below the raw quote {} once fees are taken", + quote.quote.buy_amount + ); +} diff --git a/crates/e2e/tests/e2e/main.rs b/crates/e2e/tests/e2e/main.rs index 833c4ccbcf..f44cccd28e 100644 --- a/crates/e2e/tests/e2e/main.rs +++ b/crates/e2e/tests/e2e/main.rs @@ -19,6 +19,8 @@ mod eip4626; mod eth_integration; mod eth_safe; mod ethflow; +mod fast_path_quote_promotion; +mod fast_path_settle; mod hooks; mod jit_orders; mod limit_orders;