From cc2d8decfcc2c6d6fb133cfdfa97582ea6fc28bb Mon Sep 17 00:00:00 2001 From: Aryan Godara <65490434+AryanGodara@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:50:22 +0530 Subject: [PATCH 1/5] feat: gate order entry into auctions on appData validFrom --- crates/autopilot/src/database/auction.rs | 2 +- .../src/database/onchain_order_events/mod.rs | 56 ++++- crates/autopilot/src/infra/persistence/mod.rs | 1 + crates/database/src/jit_orders.rs | 2 +- crates/database/src/orders.rs | 233 +++++++++++++++++- crates/e2e/tests/e2e/main.rs | 1 + crates/e2e/tests/e2e/quote_fastpath_flags.rs | 33 +-- crates/e2e/tests/e2e/valid_from.rs | 149 +++++++++++ crates/model/src/order.rs | 4 + crates/orderbook/openapi.yml | 7 + crates/orderbook/src/api/post_order.rs | 8 + crates/orderbook/src/database/orders.rs | 50 ++++ crates/orderbook/src/quoter.rs | 5 - crates/shared/src/db_order_conversions.rs | 5 + crates/shared/src/order_validation.rs | 77 +++++- database/README.md | 2 + .../sql/V115__add_valid_from_for_orders.sql | 4 + .../sql/V116__create_valid_from_index.sql | 3 + 18 files changed, 586 insertions(+), 56 deletions(-) create mode 100644 crates/e2e/tests/e2e/valid_from.rs create mode 100644 database/sql/V115__add_valid_from_for_orders.sql create mode 100644 database/sql/V116__create_valid_from_index.sql diff --git a/crates/autopilot/src/database/auction.rs b/crates/autopilot/src/database/auction.rs index f0c089195d..b48c867eaa 100644 --- a/crates/autopilot/src/database/auction.rs +++ b/crates/autopilot/src/database/auction.rs @@ -76,7 +76,7 @@ impl Postgres { .execute(ex.deref_mut()) .await?; let orders: HashMap> = - database::orders::solvable_orders(&mut ex, i64::from(min_valid_to)) + database::orders::solvable_orders(&mut ex, i64::from(min_valid_to), start.timestamp()) .map(|result| match result { Ok(order) => full_order_into_model_order(order) .map(|order| (domain::OrderUid(order.metadata.uid.0), Arc::new(order))), diff --git a/crates/autopilot/src/database/onchain_order_events/mod.rs b/crates/autopilot/src/database/onchain_order_events/mod.rs index 289ae0fd5e..4b107d84a0 100644 --- a/crates/autopilot/src/database/onchain_order_events/mod.rs +++ b/crates/autopilot/src/database/onchain_order_events/mod.rs @@ -318,7 +318,7 @@ impl OnchainOrderParser { .collect(); let invalidation_events = get_invalidation_events(events)?; let invalided_order_uids = extract_invalidated_order_uids(invalidation_events)?; - let (custom_onchain_data, quotes, broadcasted_order_data, orders, tx_hashes) = self + let (custom_onchain_data, quotes, broadcasted_order_data, mut orders, tx_hashes) = self .extract_custom_and_general_order_data(order_placement_events) .await?; @@ -356,7 +356,7 @@ impl OnchainOrderParser { .await .context("appending quotes for onchain orders failed")?; - insert_order_hooks(transaction, &orders, &self.trampoline) + insert_order_hooks(transaction, &mut orders, &self.trampoline) .await .context("failed to insert hooks")?; @@ -628,6 +628,9 @@ fn convert_onchain_order_placement( true => OrderClass::Limit, false => OrderClass::Market, }, + // Backfilled from the order's app-data in `insert_order_hooks` before the + // order is persisted; the full app-data isn't available at this point. + valid_from: None, }; let onchain_order_placement_event = OnchainOrderPlacement { order_uid: ByteArray(order_uid.0), @@ -680,9 +683,13 @@ fn extract_order_data_from_onchain_order_placement_event( Ok((order_data, owner, signing_scheme, order_uid)) } +/// Populates app-data-derived order fields before the orders are persisted: +/// backfills each order's `valid_from` and inserts its pre/post hook +/// interactions. Orders whose app-data is unknown or unparseable are left +/// as-is. async fn insert_order_hooks( db: &mut PgConnection, - orders: &[Order], + orders: &mut [Order], trampoline: &HooksTrampoline::Instance, ) -> Result<()> { let mut interactions_to_insert = vec![]; @@ -703,7 +710,7 @@ async fn insert_order_hooks( .to_vec() }; - for order in orders { + for order in orders.iter_mut() { let appdata_json = database::app_data::fetch(db, &order.app_data) .await .context("failed to fetch appdata")?; @@ -715,6 +722,8 @@ async fn insert_order_hooks( tracing::debug!(appdata = %String::from_utf8_lossy(&appdata_json), "could not parse appdata"); continue; }; + // Backfill the user-supplied valid_from from the app-data. + order.valid_from = parsed.valid_from.map(i64::from); if parsed.hooks.pre.is_empty() && parsed.hooks.post.is_empty() { continue; // no additional interactions to index } @@ -779,7 +788,7 @@ mod test { use { super::*, - alloy::primitives::U256, + alloy::{primitives::U256, providers::Provider}, contracts::CoWSwapOnchainOrders, database::{byte_array::ByteArray, onchain_broadcasted_orders::OnchainOrderPlacement}, ethrpc::Web3, @@ -1025,6 +1034,7 @@ mod test { sell_token_balance: sell_token_source_into(expected_order_data.sell_token_balance), buy_token_balance: buy_token_destination_into(expected_order_data.buy_token_balance), cancellation_timestamp: None, + valid_from: None, }; assert_eq!(onchain_order_placement, expected_onchain_order_placement); assert_eq!(order, expected_order); @@ -1138,11 +1148,47 @@ mod test { sell_token_balance: sell_token_source_into(expected_order_data.sell_token_balance), buy_token_balance: buy_token_destination_into(expected_order_data.buy_token_balance), cancellation_timestamp: None, + valid_from: None, }; assert_eq!(onchain_order_placement, expected_onchain_order_placement); assert_eq!(order, expected_order); } + // Onchain orders carry only the app-data hash on-chain; their `valid_from` is + // backfilled from the stored app-data document inside `insert_order_hooks` + // (the same fetch+parse that indexes hooks). + #[tokio::test] + #[ignore] + async fn postgres_insert_order_hooks_backfills_valid_from() { + let db = Postgres::with_defaults().await.unwrap(); + let mut db = db.pool.begin().await.unwrap(); + database::clear_DANGER_(&mut db).await.unwrap(); + + // App-data with a validFrom and no hooks: the trampoline is never invoked. + let app_hash = ByteArray([7u8; 32]); + let full_app_data: &[u8] = br#"{"metadata":{"validFrom":1700000000}}"#; + database::app_data::insert(&mut db, &app_hash, full_app_data) + .await + .unwrap(); + + let trampoline = HooksTrampoline::Instance::new( + Address::from([0xcf; 20]), + alloy::providers::ProviderBuilder::new() + .connect_mocked_client(alloy::providers::mock::Asserter::new()) + .erased(), + ); + + let mut orders = vec![Order { + app_data: app_hash, + ..Default::default() + }]; + insert_order_hooks(&mut db, &mut orders, &trampoline) + .await + .unwrap(); + + assert_eq!(orders[0].valid_from, Some(1_700_000_000)); + } + #[ignore] #[tokio::test] async fn extract_custom_and_general_order_data_matches_quotes_with_correct_events() { diff --git a/crates/autopilot/src/infra/persistence/mod.rs b/crates/autopilot/src/infra/persistence/mod.rs index ffd66fccc5..fe141e0d3f 100644 --- a/crates/autopilot/src/infra/persistence/mod.rs +++ b/crates/autopilot/src/infra/persistence/mod.rs @@ -580,6 +580,7 @@ impl Persistence { &mut tx, &updated_order_uids, after_timestamp, + started_at.timestamp(), ) .map(|result| match result { Ok(order) => full_order_into_model_order(order) diff --git a/crates/database/src/jit_orders.rs b/crates/database/src/jit_orders.rs index b9d1fde168..b87601686c 100644 --- a/crates/database/src/jit_orders.rs +++ b/crates/database/src/jit_orders.rs @@ -19,7 +19,7 @@ use { pub const SELECT: &str = r#" 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.fee_amount, o.kind, o.partially_fillable, o.signature, +o.valid_to, NULL AS valid_from, o.app_data, o.fee_amount, o.kind, o.partially_fillable, o.signature, o.receiver, o.signing_scheme, '\x9008d19f58aabd9ed0d60971565aa8510560ab41'::bytea AS settlement_contract, o.sell_token_balance, o.buy_token_balance, 'liquidity'::OrderClass AS class, (SELECT COALESCE(SUM(t.buy_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_buy, diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index 23d12ae1db..2171eb7734 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -99,6 +99,7 @@ pub struct Order { pub buy_token_balance: BuyTokenDestination, pub cancellation_timestamp: Option>, pub class: OrderClass, + pub valid_from: Option, } #[instrument(skip_all)] @@ -147,7 +148,8 @@ INSERT INTO orders ( buy_token_balance, cancellation_timestamp, class, - true_valid_to + true_valid_to, + valid_from ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, @@ -159,7 +161,8 @@ VALUES ( COALESCE((SELECT valid_to FROM ethflow_orders WHERE uid = $1), $21) ELSE $21 - END + END, + $22 ) "#; @@ -204,6 +207,7 @@ async fn insert_order_execute_sqlx( .bind(order.class) // true_valid_to takes the same value as valid_to when inserting an order .bind(order.valid_to) + .bind(order.valid_from) .execute(ex) .await .map(|result| result.rows_affected() > 0) @@ -502,6 +506,8 @@ pub struct FullOrder { pub sell_amount: BigDecimal, pub buy_amount: BigDecimal, pub valid_to: i64, + /// Earliest time (unix seconds) the order may enter a batch auction. + pub valid_from: Option, pub app_data: AppId, pub fee_amount: BigDecimal, pub kind: OrderKind, @@ -615,7 +621,7 @@ impl FullOrderWithQuote { // that with the current amount of data this wouldn't be better. pub const SELECT: &str = r#" 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.fee_amount, o.kind, o.partially_fillable, o.signature, +o.valid_to, o.valid_from, o.app_data, o.fee_amount, o.kind, o.partially_fillable, o.signature, o.receiver, o.signing_scheme, o.settlement_contract, o.sell_token_balance, o.buy_token_balance, o.class, (SELECT COALESCE(SUM(t.buy_amount), 0) FROM trades t WHERE t.order_uid = o.uid) AS sum_buy, @@ -735,6 +741,7 @@ WHERE pub fn solvable_orders( ex: &mut PgConnection, min_valid_to: i64, + now: i64, ) -> BoxStream<'_, Result> { /// The base solvable orders query used in specialized queries. Parametrized /// by valid_to. @@ -752,6 +759,7 @@ pub fn solvable_orders( FROM orders o WHERE o.cancellation_timestamp IS NULL AND o.true_valid_to >= $1 + AND (o.valid_from IS NULL OR o.valid_from <= $2) AND NOT EXISTS (SELECT 1 FROM invalidations i WHERE i.order_uid = o.uid) AND NOT EXISTS (SELECT 1 FROM onchain_order_invalidations oi WHERE oi.uid = o.uid) AND NOT EXISTS (SELECT 1 FROM onchain_placed_orders op WHERE op.uid = o.uid AND op.placement_error IS NOT NULL) @@ -775,6 +783,7 @@ pub fn solvable_orders( lo.sell_amount, lo.buy_amount, lo.valid_to, + lo.valid_from, lo.app_data, lo.fee_amount, lo.kind, @@ -844,7 +853,10 @@ pub fn solvable_orders( (lo.kind = 'buy' AND COALESCE(ta.sum_buy ,0) < lo.buy_amount)) "#; - sqlx::query_as(OPEN_ORDERS).bind(min_valid_to).fetch(ex) + sqlx::query_as(OPEN_ORDERS) + .bind(min_valid_to) + .bind(now) + .fetch(ex) } #[instrument(skip_all)] @@ -852,6 +864,7 @@ pub fn open_orders_by_time_or_uids<'a>( ex: &'a mut PgConnection, uids: &'a [OrderUid], after_timestamp: DateTime, + now: i64, ) -> BoxStream<'a, Result> { // Optimized version using the OPEN_ORDERS pattern with CTEs and LATERAL joins #[rustfmt::skip] @@ -859,7 +872,17 @@ pub fn open_orders_by_time_or_uids<'a>( WITH selected_orders AS ( SELECT o.* FROM orders o - WHERE (o.creation_timestamp > $1 OR o.cancellation_timestamp > $1 OR o.uid = ANY($2)) + -- First branch: recently created/cancelled/updated orders that are not + -- still gated by a future valid_from. Second branch re-selects orders whose + -- valid_from passed since the last checkpoint ($1): becoming valid is not a + -- DB update, so this is the only way such orders enter the incremental cache. + WHERE ( + (o.creation_timestamp > $1 OR o.cancellation_timestamp > $1 OR o.uid = ANY($2)) + AND (o.valid_from IS NULL OR o.valid_from <= $3) + ) + OR (o.valid_from IS NOT NULL + AND o.valid_from > EXTRACT(EPOCH FROM $1)::bigint + AND o.valid_from <= $3) ), trades_agg AS ( SELECT t.order_uid, @@ -879,6 +902,7 @@ SELECT so.sell_amount, so.buy_amount, so.valid_to, + so.valid_from, so.app_data, so.fee_amount, so.kind, @@ -953,6 +977,7 @@ LEFT JOIN trades_agg ta ON ta.order_uid = so.uid sqlx::query_as(QUERY) .bind(after_timestamp) .bind(uids) + .bind(now) .fetch(ex) } @@ -1822,7 +1847,12 @@ mod tests { insert_order(&mut db, &order).await.unwrap(); async fn get_full_order(ex: &mut PgConnection) -> Option { - solvable_orders(ex, 0).next().await.transpose().unwrap() + // now = i64::MAX so valid_from never gates these orders. + solvable_orders(ex, 0, i64::MAX) + .next() + .await + .transpose() + .unwrap() } async fn pre_signature_event( @@ -1927,7 +1957,7 @@ mod tests { insert_order(&mut db, &order).await.unwrap(); async fn get_full_order(ex: &mut PgConnection, min_valid_to: i64) -> Option { - solvable_orders(ex, min_valid_to) + solvable_orders(ex, min_valid_to, i64::MAX) .next() .await .transpose() @@ -2039,7 +2069,7 @@ mod tests { after_timestamp: DateTime, uids: &[OrderUid], ) -> HashSet { - open_orders_by_time_or_uids(ex, uids, after_timestamp) + open_orders_by_time_or_uids(ex, uids, after_timestamp, i64::MAX) .map_ok(|o| o.uid) .try_collect() .await @@ -2117,6 +2147,193 @@ mod tests { ) } + #[tokio::test] + #[ignore] + async fn postgres_solvable_orders_valid_from() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + async fn solvable_uids(ex: &mut PgConnection, now: i64) -> HashSet { + solvable_orders(ex, 0, now) + .map_ok(|o| o.uid) + .try_collect() + .await + .unwrap() + } + + let gated = Order { + uid: ByteArray([1u8; 56]), + kind: OrderKind::Sell, + sell_amount: 10.into(), + buy_amount: 100.into(), + valid_to: 100, + partially_fillable: true, + creation_timestamp: Utc::now(), + valid_from: Some(1_000), + ..Default::default() + }; + insert_order(&mut db, &gated).await.unwrap(); + + // Gated out before valid_from. + assert!(solvable_uids(&mut db, 999).await.is_empty()); + // Eligible exactly at, and after, valid_from. + assert_eq!(solvable_uids(&mut db, 1_000).await, hashset![gated.uid]); + assert_eq!(solvable_uids(&mut db, 2_000).await, hashset![gated.uid]); + + // A NULL valid_from is always eligible; the gated order stays excluded at + // now=0. + let ungated = Order { + uid: ByteArray([2u8; 56]), + kind: OrderKind::Sell, + sell_amount: 10.into(), + buy_amount: 100.into(), + valid_to: 100, + partially_fillable: true, + creation_timestamp: Utc::now(), + valid_from: None, + ..Default::default() + }; + insert_order(&mut db, &ungated).await.unwrap(); + assert_eq!(solvable_uids(&mut db, 0).await, hashset![ungated.uid]); + } + + // Incremental path: a pending->valid transition is not a DB update, so the + // second branch must re-select an order whose valid_from crossed `now` since + // the last checkpoint, even though it was created before that checkpoint. + #[tokio::test] + #[ignore] + async fn postgres_open_orders_valid_from_transition() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + async fn incremental_uids( + ex: &mut PgConnection, + after: DateTime, + now: i64, + ) -> HashSet { + open_orders_by_time_or_uids(ex, &[], after, now) + .map_ok(|o| o.uid) + .try_collect() + .await + .unwrap() + } + + // Anchor all times to one base so the margins survive second boundaries. + let base = Utc::now(); + let checkpoint = base; + let valid_from = base.timestamp() + 50; // future relative to the checkpoint + + let order = Order { + uid: ByteArray([1u8; 56]), + kind: OrderKind::Sell, + sell_amount: 10.into(), + buy_amount: 100.into(), + valid_to: 100, + partially_fillable: true, + // Created well before the checkpoint: branch 1 never picks it up. + creation_timestamp: base - Duration::seconds(100), + valid_from: Some(valid_from), + ..Default::default() + }; + insert_order(&mut db, &order).await.unwrap(); + + // Still gated (now < valid_from): neither branch selects it. + assert!( + incremental_uids(&mut db, checkpoint, valid_from - 1) + .await + .is_empty() + ); + // valid_from has now passed: branch 2 re-selects it despite no DB update. + assert_eq!( + incremental_uids(&mut db, checkpoint, valid_from).await, + hashset![order.uid] + ); + } + + // Incremental branch 1: a *recently created* order (creation_timestamp newer + // than the checkpoint) that is still gated by a future valid_from must be + // excluded until now >= valid_from. This specifically guards branch 1's gate, + // which the transition test above does not exercise (its order predates the + // checkpoint, so only branch 2 applies). + #[tokio::test] + #[ignore] + async fn postgres_open_orders_valid_from_branch1_gating() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + async fn incremental_uids( + ex: &mut PgConnection, + after: DateTime, + now: i64, + ) -> HashSet { + open_orders_by_time_or_uids(ex, &[], after, now) + .map_ok(|o| o.uid) + .try_collect() + .await + .unwrap() + } + + let base = Utc::now(); + let checkpoint = base - Duration::seconds(10); // order is created AFTER this + let valid_from = base.timestamp() + 50; + + let order = Order { + uid: ByteArray([1u8; 56]), + kind: OrderKind::Sell, + sell_amount: 10.into(), + buy_amount: 100.into(), + valid_to: 100, + partially_fillable: true, + creation_timestamp: base, + valid_from: Some(valid_from), + ..Default::default() + }; + insert_order(&mut db, &order).await.unwrap(); + + // Branch 1's time predicate matches (created after the checkpoint), but the + // valid_from gate must still keep the order out while it is pending. + assert!( + incremental_uids(&mut db, checkpoint, valid_from - 1) + .await + .is_empty() + ); + // Once valid_from passes it is included. + assert_eq!( + incremental_uids(&mut db, checkpoint, valid_from).await, + hashset![order.uid] + ); + } + + // Write->read round-trip of a concrete valid_from through the DB layer: the + // INSERT bind (orders.valid_from) and the SELECT that sources o.valid_from + // into both the `Order` row and `FullOrder`. + #[tokio::test] + #[ignore] + async fn postgres_order_valid_from_roundtrip() { + let mut db = PgConnection::connect("postgresql://").await.unwrap(); + let mut db = db.begin().await.unwrap(); + crate::clear_DANGER_(&mut db).await.unwrap(); + + let order = Order { + valid_from: Some(1_700_000_000), + ..Default::default() + }; + insert_order(&mut db, &order).await.unwrap(); + + let read = read_order(&mut db, &order.uid).await.unwrap().unwrap(); + assert_eq!(read.valid_from, Some(1_700_000_000)); + + let full = single_full_order_with_quote(&mut db, &order.uid) + .await + .unwrap() + .unwrap() + .full_order; + assert_eq!(full.valid_from, Some(1_700_000_000)); + } + #[tokio::test] #[ignore] async fn postgres_orders_in_tx() { diff --git a/crates/e2e/tests/e2e/main.rs b/crates/e2e/tests/e2e/main.rs index e0d71a1a3d..17d4e3d13d 100644 --- a/crates/e2e/tests/e2e/main.rs +++ b/crates/e2e/tests/e2e/main.rs @@ -50,4 +50,5 @@ mod trades_v2; mod uncovered_order; mod univ2; mod user_surplus; +mod valid_from; mod wrapper; diff --git a/crates/e2e/tests/e2e/quote_fastpath_flags.rs b/crates/e2e/tests/e2e/quote_fastpath_flags.rs index 1e173695af..10c8aa173b 100644 --- a/crates/e2e/tests/e2e/quote_fastpath_flags.rs +++ b/crates/e2e/tests/e2e/quote_fastpath_flags.rs @@ -23,8 +23,8 @@ async fn local_node_quote_fastpath_flags_rejected() { } /// Verifies that the orderbook rejects quotes and orders that use the -/// not-yet-supported `fast_path` quote flag or `validFrom`/`enableFastPath` -/// app-data fields. +/// not-yet-supported `fast_path` quote flag or `enableFastPath` app-data field. +/// (`validFrom` is supported — see the `valid_from` e2e test.) async fn quote_fastpath_flags_rejected(web3: Web3) { let mut onchain = OnchainComponents::deploy(web3).await; let [trader] = onchain.make_accounts(1u64.eth()).await; @@ -77,23 +77,6 @@ async fn quote_fastpath_flags_rejected(web3: Web3) { err.1 ); - // --- quote: validFrom in app data --- - let err = services - .submit_quote(&OrderQuoteRequest { - app_data: OrderCreationAppData::Full { - full: r#"{"metadata":{"validFrom":1700000000}}"#.to_string(), - }, - ..base_quote() - }) - .await - .unwrap_err(); - assert_eq!(err.0, StatusCode::BAD_REQUEST); - assert!( - err.1.contains("validFrom"), - "error body should mention validFrom, got: {}", - err.1 - ); - // For order tests, validate_app_data fires before signature verification so // we just need structurally valid (but cryptographically incorrect) orders. let valid_to = model::time::now_in_epoch_seconds() + 300; @@ -129,16 +112,4 @@ async fn quote_fastpath_flags_rejected(web3: Web3) { "error body should mention enableFastPath, got: {}", err.1 ); - - // --- order: validFrom in app data --- - let err = services - .create_order(&make_order(r#"{"metadata":{"validFrom":1700000000}}"#)) - .await - .unwrap_err(); - assert_eq!(err.0, StatusCode::BAD_REQUEST); - assert!( - err.1.contains("validFrom"), - "error body should mention validFrom, got: {}", - err.1 - ); } diff --git a/crates/e2e/tests/e2e/valid_from.rs b/crates/e2e/tests/e2e/valid_from.rs new file mode 100644 index 0000000000..12c99c5db9 --- /dev/null +++ b/crates/e2e/tests/e2e/valid_from.rs @@ -0,0 +1,149 @@ +use { + ::alloy::primitives::U256, + e2e::setup::*, + ethrpc::alloy::CallBuilderExt, + model::{ + order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus}, + signature::EcdsaSigningScheme, + }, + number::units::EthUnit, + shared::web3::Web3, + std::time::Duration, +}; + +#[tokio::test] +#[ignore] +async fn local_node_valid_from_gates_auction_entry() { + run_test(valid_from_gates_auction_entry).await; +} + +/// An order whose app-data sets a future `validFrom` must not enter the +/// autopilot's auction until `now >= validFrom`, after which it is picked up +/// and settled like any other order. +async fn valid_from_gates_auction_entry(web3: Web3) { + let mut onchain = OnchainComponents::deploy(web3.clone()).await; + + let [solver] = onchain.make_solvers(1u64.eth()).await; + let [trader] = onchain.make_accounts(1u64.eth()).await; + let [token_a, token_b] = onchain + .deploy_tokens_with_weth_uni_v2_pools(1_000u64.eth(), 1_000u64.eth()) + .await; + + token_a.mint(trader.address(), 10u64.eth()).await; + token_a.mint(solver.address(), 1_000u64.eth()).await; + token_b.mint(solver.address(), 1_000u64.eth()).await; + + onchain + .contracts() + .uniswap_v2_factory + .createPair(*token_a.address(), *token_b.address()) + .from(solver.address()) + .send_and_watch() + .await + .unwrap(); + token_a + .approve( + *onchain.contracts().uniswap_v2_router.address(), + 1_000u64.eth(), + ) + .from(solver.address()) + .send_and_watch() + .await + .unwrap(); + token_b + .approve( + *onchain.contracts().uniswap_v2_router.address(), + 1_000u64.eth(), + ) + .from(solver.address()) + .send_and_watch() + .await + .unwrap(); + onchain + .contracts() + .uniswap_v2_router + .addLiquidity( + *token_a.address(), + *token_b.address(), + 1_000u64.eth(), + 1_000u64.eth(), + U256::ZERO, + U256::ZERO, + solver.address(), + U256::MAX, + ) + .from(solver.address()) + .send_and_watch() + .await + .unwrap(); + + token_a + .approve(onchain.contracts().allowance, 10u64.eth()) + .from(trader.address()) + .send_and_watch() + .await + .unwrap(); + + let services = Services::new(&onchain).await; + services.start_protocol(solver).await; + + // Gate the order behind a `validFrom` a fixed window in the future. + const GATE_SECONDS: u32 = 15; + let valid_from = model::time::now_in_epoch_seconds() + GATE_SECONDS; + let order = OrderCreation { + sell_token: *token_a.address(), + sell_amount: 10u64.eth(), + buy_token: *token_b.address(), + buy_amount: 5u64.eth(), + valid_to: model::time::now_in_epoch_seconds() + 300, + kind: OrderKind::Sell, + app_data: OrderCreationAppData::Full { + full: format!(r#"{{"metadata":{{"validFrom":{valid_from}}}}}"#), + }, + ..Default::default() + } + .sign( + EcdsaSigningScheme::Eip712, + &onchain.contracts().domain_separator, + &trader.signer, + ); + + let balance_before = token_b.balanceOf(trader.address()).call().await.unwrap(); + let order_id = services.create_order(&order).await.unwrap(); + + // The order is accepted and open, just not yet eligible for the auction. + onchain.mint_block().await; + assert_eq!( + services.get_order(&order_id).await.unwrap().metadata.status, + OrderStatus::Open, + ); + + // For the first part of the gating window the order must neither enter the + // auction nor settle. An equivalent ungated order settles within a couple of + // seconds, so sustained absence here is the gating, not latency. The balance + // guard also catches a broken gate that settled and already left the auction. + let gate_until = std::time::Instant::now() + Duration::from_secs((GATE_SECONDS / 2) as u64); + while std::time::Instant::now() < gate_until { + onchain.mint_block().await; + assert!( + services.get_auction().await.auction.orders.is_empty(), + "gated order entered the auction before validFrom", + ); + let balance = token_b.balanceOf(trader.address()).call().await.unwrap(); + assert_eq!( + balance, balance_before, + "gated order settled before validFrom" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Once validFrom passes, the order is picked up and settles. + tracing::info!("waiting for the gated order to settle after validFrom"); + wait_for_condition(TIMEOUT, || async { + onchain.mint_block().await; + let balance = token_b.balanceOf(trader.address()).call().await.unwrap(); + balance.checked_sub(balance_before).unwrap() >= 5u64.eth() + }) + .await + .unwrap(); +} diff --git a/crates/model/src/order.rs b/crates/model/src/order.rs index 20508c154b..cffaf361ee 100644 --- a/crates/model/src/order.rs +++ b/crates/model/src/order.rs @@ -727,6 +727,10 @@ pub struct OrderMetadata { /// Full app data that `OrderData::app_data` is a hash of. Can be None if /// the backend doesn't know about the full app data. pub full_app_data: Option, + /// Earliest time (unix seconds) at which the order may enter a batch + /// auction. `None` means no lower bound (eligible immediately). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_from: Option, /// If the order was created with a quote, then this field contains that /// quote data for reference. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index dd97064c29..81544722c0 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1408,6 +1408,13 @@ components: `OrderCreation` for more information. type: string nullable: true + validFrom: + description: > + Earliest time (unix seconds) at which the order may enter a batch + auction, taken from the order's `appData`. Omitted when the order + has no lower bound (eligible immediately). + type: integer + nullable: true settlementContract: description: > The address of the CoW Protocol settlement contract that this order diff --git a/crates/orderbook/src/api/post_order.rs b/crates/orderbook/src/api/post_order.rs index 9db253913b..02c927dfbc 100644 --- a/crates/orderbook/src/api/post_order.rs +++ b/crates/orderbook/src/api/post_order.rs @@ -251,6 +251,14 @@ impl IntoResponse for ValidationErrorWrapper { error("ZeroAmount", "Buy or sell amount is zero."), ) .into_response(), + ValidationError::InvalidValidFrom => ( + StatusCode::BAD_REQUEST, + error( + "InvalidValidFrom", + "appData validFrom must be earlier than the order's validTo.", + ), + ) + .into_response(), ValidationError::IncompatibleSigningScheme => ( StatusCode::BAD_REQUEST, error( diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 081336e3ed..98524f34e9 100644 --- a/crates/orderbook/src/database/orders.rs +++ b/crates/orderbook/src/database/orders.rs @@ -176,6 +176,7 @@ async fn insert_order(order: &Order, ex: &mut PgConnection) -> Result<(), Insert sell_token_balance: sell_token_source_into(order.data.sell_token_balance), buy_token_balance: buy_token_destination_into(order.data.buy_token_balance), cancellation_timestamp: None, + valid_from: order.metadata.valid_from.map(i64::from), }; database::orders::insert_order(ex, &db_order) @@ -622,6 +623,11 @@ fn full_order_with_quote_into_model_order( .map(String::from_utf8) .transpose() .context("full app data isn't utf-8")?, + valid_from: order + .valid_from + .map(u32::try_from) + .transpose() + .context("valid_from is not u32")?, quote: quote .map(|q| order_quote_into_model(q, status)) .transpose()?, @@ -695,6 +701,49 @@ mod tests { std::sync::atomic::{AtomicI64, Ordering}, }; + // The DB->model read conversion preserves a concrete valid_from (i64 -> + // Option), the value surfaced on the order API responses. + #[test] + fn valid_from_survives_db_to_model_conversion() { + let full_order = FullOrder { + uid: ByteArray([0; 56]), + owner: ByteArray([0; 20]), + creation_timestamp: Utc::now(), + sell_token: ByteArray([1; 20]), + buy_token: ByteArray([2; 20]), + sell_amount: BigDecimal::from(1), + buy_amount: BigDecimal::from(1), + valid_to: (Utc::now() + Duration::days(1)).timestamp(), + valid_from: Some(1_700_000_000), + app_data: ByteArray([0; 32]), + fee_amount: BigDecimal::default(), + kind: DbOrderKind::Sell, + class: DbOrderClass::Liquidity, + partially_fillable: true, + signature: vec![0; 65], + receiver: None, + sum_sell: BigDecimal::default(), + sum_buy: BigDecimal::default(), + sum_fee: BigDecimal::default(), + invalidated: false, + signing_scheme: DbSigningScheme::Eip712, + settlement_contract: ByteArray([0; 20]), + sell_token_balance: DbSellTokenSource::External, + buy_token_balance: DbBuyTokenDestination::Internal, + presignature_pending: false, + pre_interactions: Vec::new(), + post_interactions: Vec::new(), + ethflow_data: None, + onchain_user: None, + onchain_placement_error: None, + executed_fee: Default::default(), + executed_fee_token: ByteArray([1; 20]), + full_app_data: Default::default(), + }; + let order = full_order_into_model_order(full_order).unwrap(); + assert_eq!(order.metadata.valid_from, Some(1_700_000_000)); + } + #[test] fn order_status() { let valid_to_timestamp = Utc::now() + Duration::days(1); @@ -708,6 +757,7 @@ mod tests { sell_amount: BigDecimal::from(1), buy_amount: BigDecimal::from(1), valid_to: valid_to_timestamp.timestamp(), + valid_from: None, app_data: ByteArray([0; 32]), fee_amount: BigDecimal::default(), kind: DbOrderKind::Sell, diff --git a/crates/orderbook/src/quoter.rs b/crates/orderbook/src/quoter.rs index 7c9344eb03..4953ff9e08 100644 --- a/crates/orderbook/src/quoter.rs +++ b/crates/orderbook/src/quoter.rs @@ -276,11 +276,6 @@ impl QuoteHandler { anyhow::anyhow!("'enableFastPath' is not yet supported"), ))); } - if app_data.inner.protocol.valid_from.is_some() { - return Err(OrderQuoteError::AppData(AppDataValidationError::Invalid( - anyhow::anyhow!("'validFrom' is not yet supported"), - ))); - } // Emit only after validation succeeds so we don't announce requests // that never reach the estimator (invalid app-data / order data return diff --git a/crates/shared/src/db_order_conversions.rs b/crates/shared/src/db_order_conversions.rs index 96e90bdf6d..3f114dc79b 100644 --- a/crates/shared/src/db_order_conversions.rs +++ b/crates/shared/src/db_order_conversions.rs @@ -99,6 +99,11 @@ pub fn full_order_into_model_order(order: database::orders::FullOrder) -> Result .map(String::from_utf8) .transpose() .context("full app data isn't utf-8")?, + valid_from: order + .valid_from + .map(u32::try_from) + .transpose() + .context("valid_from is not u32")?, quote: None, }; let data = OrderData { diff --git a/crates/shared/src/order_validation.rs b/crates/shared/src/order_validation.rs index b1998093ee..e9f8a0d7fb 100644 --- a/crates/shared/src/order_validation.rs +++ b/crates/shared/src/order_validation.rs @@ -275,6 +275,9 @@ pub enum ValidationError { /// reverted or did not return the expected value. InvalidEip1271Signature(B256), ZeroAmount, + /// `valid_from` is at or after `valid_to`, so the order could never be + /// valid. + InvalidValidFrom, IncompatibleSigningScheme, TooManyLimitOrders, TooMuchGas, @@ -765,11 +768,6 @@ impl OrderValidating for OrderValidator { "'enableFastPath' is not yet supported" ))); } - if app_data.protocol.valid_from.is_some() { - return Err(AppDataValidationError::Invalid(anyhow::anyhow!( - "'validFrom' is not yet supported" - ))); - } let interactions = self.custom_interactions(&app_data.protocol.hooks); Ok(OrderAppData { @@ -1023,6 +1021,18 @@ impl OrderValidating for OrderValidator { return Err(ValidationError::TooMuchGas); } + // A `valid_from` at or after `valid_to` makes the order impossible to ever + // be valid. A `valid_from` in the past is allowed: the order is simply + // eligible immediately. + if app_data + .inner + .protocol + .valid_from + .is_some_and(|valid_from| valid_from >= data.valid_to) + { + return Err(ValidationError::InvalidValidFrom); + } + let order = Order { metadata: OrderMetadata { owner, @@ -1040,6 +1050,7 @@ impl OrderValidating for OrderValidator { .map(|q| q.try_to_model_order_quote()) .transpose() .map_err(ValidationError::Other)?, + valid_from: app_data.inner.protocol.valid_from, ..Default::default() }, signature: order.signature.clone(), @@ -2173,6 +2184,62 @@ mod tests { assert!(matches!(result, Err(ValidationError::ZeroAmount))); } + #[tokio::test] + async fn post_validate_err_invalid_valid_from() { + let mut order_quoter = MockOrderQuoting::new(); + let mut balance_fetcher = MockBalanceFetching::new(); + order_quoter + .expect_find_quote() + .returning(|_, _| Ok(Default::default())); + balance_fetcher + .expect_can_transfer() + .returning(|_, _| Ok(())); + let mut limit_order_counter = MockLimitOrderCounting::new(); + limit_order_counter.expect_count().returning(|_| Ok(0u64)); + let native_token = WETH9::Instance::new([0xef; 20].into(), ethrpc::mock::web3().provider); + let validator = OrderValidator::new( + native_token, + Arc::new(order_validation::banned::Users::none()), + OrderValidPeriodConfiguration::any(), + false, + Default::default(), + HooksTrampoline::Instance::new( + Address::repeat_byte(0xcf), + ProviderBuilder::new() + .connect_mocked_client(Asserter::new()) + .erased(), + ), + Arc::new(order_quoter), + Arc::new(balance_fetcher), + Arc::new(MockSignatureValidating::new()), + None, + Arc::new(limit_order_counter), + 0, + Default::default(), + u64::MAX, + SameTokensPolicy::Disallow, + ); + // `validFrom == valid_to` makes the order impossible to ever be valid. + let valid_to = time::now_in_epoch_seconds() + 2; + let order = OrderCreation { + valid_to, + sell_token: Address::with_last_byte(1), + buy_token: Address::with_last_byte(2), + buy_amount: alloy::primitives::U256::from(1), + sell_amount: alloy::primitives::U256::from(1), + fee_amount: alloy::primitives::U256::from(0), + signature: Signature::Eip712(EcdsaSignature::non_zero()), + app_data: OrderCreationAppData::Full { + full: format!(r#"{{"metadata":{{"validFrom":{valid_to}}}}}"#), + }, + ..Default::default() + }; + let result = validator + .validate_and_construct_order(order, &Default::default(), Default::default(), None) + .await; + assert!(matches!(result, Err(ValidationError::InvalidValidFrom))); + } + #[tokio::test] async fn post_validate_err_wrong_owner() { let mut order_quoter = MockOrderQuoting::new(); diff --git a/database/README.md b/database/README.md index 01063d9bb7..222cc7af0c 100644 --- a/database/README.md +++ b/database/README.md @@ -268,6 +268,7 @@ Column | Type | Nullable | Details buy\_token\_balance | [enum](#buytokendestination) | not null | defined how buy\_tokens need to be transferred back to the user class | [enum](#orderclass) | not null | determines which special trade semantics will apply to the execution of this order true_valid_to | timestamptz | not null | timestamp at which order is no longer executable. For regular orders it is the same value as valid_to. Some orders may have multiple valid_to values, such as ethflow: which is initially signed with u32::MAX. Their true validity comes from the Settlement contract's events which is used for liveness checks. + valid\_from | bigint | nullable | earliest UNIX timestamp (in seconds) at which the order may enter a batch auction. Taken from the order's app-data (`validFrom`). NULL means no lower bound, i.e. the order is eligible immediately (the default for all existing orders). Indexes: - PRIMARY KEY: btree(`uid`) @@ -279,6 +280,7 @@ Indexes: - user_order_creation_timestamp: btree(`owner`, `creation_timestamp` DESC) - version_idx: btree(`settlement_contract`) - orders\_true\_valid\_to: btree(`true_valid_to`) +- orders\_valid\_from: btree(`valid_from`) WHERE valid_from IS NOT NULL - orders_owner_covering: btree(`owner`) INCLUDE (`uid`, `kind`, `buy_amount`, `sell_amount`, `fee_amount`, `buy_token`, `sell_token`) - orders_owner_class_valid_composite: btree(`owner`, `class`, `true_valid_to` DESC) WHERE cancellation_timestamp IS NULL diff --git a/database/sql/V115__add_valid_from_for_orders.sql b/database/sql/V115__add_valid_from_for_orders.sql new file mode 100644 index 0000000000..ef4858e575 --- /dev/null +++ b/database/sql/V115__add_valid_from_for_orders.sql @@ -0,0 +1,4 @@ +-- `valid_from` is the earliest UNIX timestamp (in seconds) at which an order may +-- enter a batch auction. NULL means no lower bound, i.e. the order is eligible +-- immediately (the behaviour for all existing orders), so no backfill is needed. +ALTER TABLE orders ADD COLUMN valid_from bigint; diff --git a/database/sql/V116__create_valid_from_index.sql b/database/sql/V116__create_valid_from_index.sql new file mode 100644 index 0000000000..5e57aa7880 --- /dev/null +++ b/database/sql/V116__create_valid_from_index.sql @@ -0,0 +1,3 @@ +-- Speeds up the `valid_from` filter in the solvable-orders lookups. Only orders +-- that actually set a `valid_from` are indexed; the vast majority are NULL. +CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_valid_from ON orders USING btree (valid_from) WHERE valid_from IS NOT NULL; From e42808d447d108e1ceeeba1d7b83117301b1b29a Mon Sep 17 00:00:00 2001 From: Aryan Godara <65490434+AryanGodara@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:14:46 +0530 Subject: [PATCH 2/5] Cover validFrom on ethflow orders and fix true_valid_to doc type --- crates/e2e/tests/e2e/valid_from.rs | 172 +++++++++++++---------------- database/README.md | 2 +- 2 files changed, 77 insertions(+), 97 deletions(-) diff --git a/crates/e2e/tests/e2e/valid_from.rs b/crates/e2e/tests/e2e/valid_from.rs index 12c99c5db9..e039d6b4c6 100644 --- a/crates/e2e/tests/e2e/valid_from.rs +++ b/crates/e2e/tests/e2e/valid_from.rs @@ -1,82 +1,39 @@ use { - ::alloy::primitives::U256, + crate::ethflow::ExtendedEthFlowOrder, + ::alloy::primitives::{Address, U256}, + contracts::CoWSwapEthFlow, e2e::setup::*, - ethrpc::alloy::CallBuilderExt, + ethrpc::{Web3, alloy::CallBuilderExt}, model::{ - order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus}, + order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus, OrderUid}, signature::EcdsaSigningScheme, }, number::units::EthUnit, - shared::web3::Web3, - std::time::Duration, }; +/// Seconds a `validFrom` is set into the future; large enough to observe the +/// order held out of the auction across several autopilot cycles. +const GATE_SECS: u32 = 6; + #[tokio::test] #[ignore] -async fn local_node_valid_from_gates_auction_entry() { - run_test(valid_from_gates_auction_entry).await; +async fn local_node_valid_from() { + run_test(valid_from_test).await; } -/// An order whose app-data sets a future `validFrom` must not enter the -/// autopilot's auction until `now >= validFrom`, after which it is picked up -/// and settled like any other order. -async fn valid_from_gates_auction_entry(web3: Web3) { +/// `validFrom` (app-data, unix seconds) holds an order out of the batch auction +/// until `now >= validFrom`. Verified for a regular EIP-712 order and for an +/// on-chain ethflow order, whose `validFrom` is backfilled from the app-data. +async fn valid_from_test(web3: Web3) { let mut onchain = OnchainComponents::deploy(web3.clone()).await; - let [solver] = onchain.make_solvers(1u64.eth()).await; - let [trader] = onchain.make_accounts(1u64.eth()).await; + let [solver] = onchain.make_solvers(10u64.eth()).await; + let [trader] = onchain.make_accounts(10u64.eth()).await; let [token_a, token_b] = onchain .deploy_tokens_with_weth_uni_v2_pools(1_000u64.eth(), 1_000u64.eth()) .await; token_a.mint(trader.address(), 10u64.eth()).await; - token_a.mint(solver.address(), 1_000u64.eth()).await; - token_b.mint(solver.address(), 1_000u64.eth()).await; - - onchain - .contracts() - .uniswap_v2_factory - .createPair(*token_a.address(), *token_b.address()) - .from(solver.address()) - .send_and_watch() - .await - .unwrap(); - token_a - .approve( - *onchain.contracts().uniswap_v2_router.address(), - 1_000u64.eth(), - ) - .from(solver.address()) - .send_and_watch() - .await - .unwrap(); - token_b - .approve( - *onchain.contracts().uniswap_v2_router.address(), - 1_000u64.eth(), - ) - .from(solver.address()) - .send_and_watch() - .await - .unwrap(); - onchain - .contracts() - .uniswap_v2_router - .addLiquidity( - *token_a.address(), - *token_b.address(), - 1_000u64.eth(), - 1_000u64.eth(), - U256::ZERO, - U256::ZERO, - solver.address(), - U256::MAX, - ) - .from(solver.address()) - .send_and_watch() - .await - .unwrap(); - token_a .approve(onchain.contracts().allowance, 10u64.eth()) .from(trader.address()) @@ -87,14 +44,13 @@ async fn valid_from_gates_auction_entry(web3: Web3) { let services = Services::new(&onchain).await; services.start_protocol(solver).await; - // Gate the order behind a `validFrom` a fixed window in the future. - const GATE_SECONDS: u32 = 15; - let valid_from = model::time::now_in_epoch_seconds() + GATE_SECONDS; + // Regular EIP-712 order gated by a future validFrom in its app-data. + let valid_from = model::time::now_in_epoch_seconds() + GATE_SECS; let order = OrderCreation { sell_token: *token_a.address(), - sell_amount: 10u64.eth(), + sell_amount: 5u64.eth(), buy_token: *token_b.address(), - buy_amount: 5u64.eth(), + buy_amount: 1u64.eth(), valid_to: model::time::now_in_epoch_seconds() + 300, kind: OrderKind::Sell, app_data: OrderCreationAppData::Full { @@ -107,42 +63,66 @@ async fn valid_from_gates_auction_entry(web3: Web3) { &onchain.contracts().domain_separator, &trader.signer, ); + let uid = services.create_order(&order).await.unwrap(); + settles_only_after_valid_from(&onchain, &services, uid, valid_from).await; - let balance_before = token_b.balanceOf(trader.address()).call().await.unwrap(); - let order_id = services.create_order(&order).await.unwrap(); - - // The order is accepted and open, just not yet eligible for the auction. - onchain.mint_block().await; - assert_eq!( - services.get_order(&order_id).await.unwrap().metadata.status, - OrderStatus::Open, - ); + // On-chain ethflow order: the app-data only exists behind its hash on-chain, + // so validFrom is backfilled while indexing the order. + let ethflow_valid_from = model::time::now_in_epoch_seconds() + GATE_SECS; + let ethflow_app_data = format!(r#"{{"metadata":{{"validFrom":{ethflow_valid_from}}}}}"#); + let app_data_hash = services + .put_app_data(None, ðflow_app_data) + .await + .unwrap(); + let app_data_hash: [u8; 32] = const_hex::decode(&app_data_hash[2..]) + .unwrap() + .try_into() + .unwrap(); - // For the first part of the gating window the order must neither enter the - // auction nor settle. An equivalent ungated order settles within a couple of - // seconds, so sustained absence here is the gating, not latency. The balance - // guard also catches a broken gate that settled and already left the auction. - let gate_until = std::time::Instant::now() + Duration::from_secs((GATE_SECONDS / 2) as u64); - while std::time::Instant::now() < gate_until { - onchain.mint_block().await; - assert!( - services.get_auction().await.auction.orders.is_empty(), - "gated order entered the auction before validFrom", - ); - let balance = token_b.balanceOf(trader.address()).call().await.unwrap(); - assert_eq!( - balance, balance_before, - "gated order settled before validFrom" - ); - tokio::time::sleep(Duration::from_millis(500)).await; - } + let ethflow_contract = onchain.contracts().ethflows.first().unwrap(); + let ethflow_order = ExtendedEthFlowOrder(CoWSwapEthFlow::EthFlowOrder::Data { + buyToken: *token_b.address(), + sellAmount: 1u64.eth(), + buyAmount: U256::ONE, + validTo: model::time::now_in_epoch_seconds() + 3600, + partiallyFillable: false, + quoteId: 0, + feeAmount: U256::ZERO, + receiver: Address::from_slice(&[0x43; 20]), + appData: app_data_hash.into(), + }); + ethflow_order + .mine_order_creation(trader.address(), ethflow_contract) + .await; + let ethflow_uid = ethflow_order + .uid(onchain.contracts(), ethflow_contract) + .await; + settles_only_after_valid_from(&onchain, &services, ethflow_uid, ethflow_valid_from).await; +} - // Once validFrom passes, the order is picked up and settles. - tracing::info!("waiting for the gated order to settle after validFrom"); +/// Asserts the order never settles while `now < valid_from`, then settles once +/// `validFrom` has passed. +async fn settles_only_after_valid_from( + onchain: &OnchainComponents, + services: &Services<'_>, + uid: OrderUid, + valid_from: u32, +) { wait_for_condition(TIMEOUT, || async { onchain.mint_block().await; - let balance = token_b.balanceOf(trader.address()).call().await.unwrap(); - balance.checked_sub(balance_before).unwrap() >= 5u64.eth() + let Ok(order) = services.get_order(&uid).await else { + return false; + }; + if model::time::now_in_epoch_seconds() < valid_from { + assert_eq!( + order.metadata.status, + OrderStatus::Open, + "order settled before validFrom", + ); + false + } else { + order.metadata.status == OrderStatus::Fulfilled + } }) .await .unwrap(); diff --git a/database/README.md b/database/README.md index 222cc7af0c..14d6c3e762 100644 --- a/database/README.md +++ b/database/README.md @@ -267,7 +267,7 @@ Column | Type | Nullable | Details sell\_token\_balance | [enum](#selltokensource) | not null | defines how sell\_tokens need to be transferred into the settlement contract buy\_token\_balance | [enum](#buytokendestination) | not null | defined how buy\_tokens need to be transferred back to the user class | [enum](#orderclass) | not null | determines which special trade semantics will apply to the execution of this order - true_valid_to | timestamptz | not null | timestamp at which order is no longer executable. For regular orders it is the same value as valid_to. Some orders may have multiple valid_to values, such as ethflow: which is initially signed with u32::MAX. Their true validity comes from the Settlement contract's events which is used for liveness checks. + true_valid_to | bigint | not null | UNIX timestamp at which order is no longer executable. For regular orders it is the same value as valid_to. Some orders may have multiple valid_to values, such as ethflow: which is initially signed with u32::MAX. Their true validity comes from the Settlement contract's events which is used for liveness checks. valid\_from | bigint | nullable | earliest UNIX timestamp (in seconds) at which the order may enter a batch auction. Taken from the order's app-data (`validFrom`). NULL means no lower bound, i.e. the order is eligible immediately (the default for all existing orders). Indexes: From 59efde5f282550e69943543245c01ca4a6b76c8b Mon Sep 17 00:00:00 2001 From: Aryan Godara <65490434+AryanGodara@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:58:15 +0530 Subject: [PATCH 3/5] remove redundant valid_from tests and tighten comments --- .../src/database/onchain_order_events/mod.rs | 41 +-------- crates/database/src/orders.rs | 91 +------------------ crates/orderbook/src/database/orders.rs | 43 --------- crates/shared/src/order_validation.rs | 56 ------------ 4 files changed, 4 insertions(+), 227 deletions(-) diff --git a/crates/autopilot/src/database/onchain_order_events/mod.rs b/crates/autopilot/src/database/onchain_order_events/mod.rs index 4b107d84a0..f8c54d7df2 100644 --- a/crates/autopilot/src/database/onchain_order_events/mod.rs +++ b/crates/autopilot/src/database/onchain_order_events/mod.rs @@ -683,10 +683,6 @@ fn extract_order_data_from_onchain_order_placement_event( Ok((order_data, owner, signing_scheme, order_uid)) } -/// Populates app-data-derived order fields before the orders are persisted: -/// backfills each order's `valid_from` and inserts its pre/post hook -/// interactions. Orders whose app-data is unknown or unparseable are left -/// as-is. async fn insert_order_hooks( db: &mut PgConnection, orders: &mut [Order], @@ -788,7 +784,7 @@ mod test { use { super::*, - alloy::{primitives::U256, providers::Provider}, + alloy::primitives::U256, contracts::CoWSwapOnchainOrders, database::{byte_array::ByteArray, onchain_broadcasted_orders::OnchainOrderPlacement}, ethrpc::Web3, @@ -1154,41 +1150,6 @@ mod test { assert_eq!(order, expected_order); } - // Onchain orders carry only the app-data hash on-chain; their `valid_from` is - // backfilled from the stored app-data document inside `insert_order_hooks` - // (the same fetch+parse that indexes hooks). - #[tokio::test] - #[ignore] - async fn postgres_insert_order_hooks_backfills_valid_from() { - let db = Postgres::with_defaults().await.unwrap(); - let mut db = db.pool.begin().await.unwrap(); - database::clear_DANGER_(&mut db).await.unwrap(); - - // App-data with a validFrom and no hooks: the trampoline is never invoked. - let app_hash = ByteArray([7u8; 32]); - let full_app_data: &[u8] = br#"{"metadata":{"validFrom":1700000000}}"#; - database::app_data::insert(&mut db, &app_hash, full_app_data) - .await - .unwrap(); - - let trampoline = HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), - alloy::providers::ProviderBuilder::new() - .connect_mocked_client(alloy::providers::mock::Asserter::new()) - .erased(), - ); - - let mut orders = vec![Order { - app_data: app_hash, - ..Default::default() - }]; - insert_order_hooks(&mut db, &mut orders, &trampoline) - .await - .unwrap(); - - assert_eq!(orders[0].valid_from, Some(1_700_000_000)); - } - #[ignore] #[tokio::test] async fn extract_custom_and_general_order_data_matches_quotes_with_correct_events() { diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index 2171eb7734..2fbe36c4ef 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -872,10 +872,9 @@ pub fn open_orders_by_time_or_uids<'a>( WITH selected_orders AS ( SELECT o.* FROM orders o - -- First branch: recently created/cancelled/updated orders that are not - -- still gated by a future valid_from. Second branch re-selects orders whose - -- valid_from passed since the last checkpoint ($1): becoming valid is not a - -- DB update, so this is the only way such orders enter the incremental cache. + -- Branch 1 is the usual "changed since $1" set, gated by valid_from. Branch 2 + -- re-selects orders whose valid_from crossed since $1 -- becoming valid isn't a + -- DB write, so branch 1 would otherwise never pick them up. WHERE ( (o.creation_timestamp > $1 OR o.cancellation_timestamp > $1 OR o.uid = ANY($2)) AND (o.valid_from IS NULL OR o.valid_from <= $3) @@ -2181,8 +2180,6 @@ mod tests { assert_eq!(solvable_uids(&mut db, 1_000).await, hashset![gated.uid]); assert_eq!(solvable_uids(&mut db, 2_000).await, hashset![gated.uid]); - // A NULL valid_from is always eligible; the gated order stays excluded at - // now=0. let ungated = Order { uid: ByteArray([2u8; 56]), kind: OrderKind::Sell, @@ -2252,88 +2249,6 @@ mod tests { ); } - // Incremental branch 1: a *recently created* order (creation_timestamp newer - // than the checkpoint) that is still gated by a future valid_from must be - // excluded until now >= valid_from. This specifically guards branch 1's gate, - // which the transition test above does not exercise (its order predates the - // checkpoint, so only branch 2 applies). - #[tokio::test] - #[ignore] - async fn postgres_open_orders_valid_from_branch1_gating() { - let mut db = PgConnection::connect("postgresql://").await.unwrap(); - let mut db = db.begin().await.unwrap(); - crate::clear_DANGER_(&mut db).await.unwrap(); - - async fn incremental_uids( - ex: &mut PgConnection, - after: DateTime, - now: i64, - ) -> HashSet { - open_orders_by_time_or_uids(ex, &[], after, now) - .map_ok(|o| o.uid) - .try_collect() - .await - .unwrap() - } - - let base = Utc::now(); - let checkpoint = base - Duration::seconds(10); // order is created AFTER this - let valid_from = base.timestamp() + 50; - - let order = Order { - uid: ByteArray([1u8; 56]), - kind: OrderKind::Sell, - sell_amount: 10.into(), - buy_amount: 100.into(), - valid_to: 100, - partially_fillable: true, - creation_timestamp: base, - valid_from: Some(valid_from), - ..Default::default() - }; - insert_order(&mut db, &order).await.unwrap(); - - // Branch 1's time predicate matches (created after the checkpoint), but the - // valid_from gate must still keep the order out while it is pending. - assert!( - incremental_uids(&mut db, checkpoint, valid_from - 1) - .await - .is_empty() - ); - // Once valid_from passes it is included. - assert_eq!( - incremental_uids(&mut db, checkpoint, valid_from).await, - hashset![order.uid] - ); - } - - // Write->read round-trip of a concrete valid_from through the DB layer: the - // INSERT bind (orders.valid_from) and the SELECT that sources o.valid_from - // into both the `Order` row and `FullOrder`. - #[tokio::test] - #[ignore] - async fn postgres_order_valid_from_roundtrip() { - let mut db = PgConnection::connect("postgresql://").await.unwrap(); - let mut db = db.begin().await.unwrap(); - crate::clear_DANGER_(&mut db).await.unwrap(); - - let order = Order { - valid_from: Some(1_700_000_000), - ..Default::default() - }; - insert_order(&mut db, &order).await.unwrap(); - - let read = read_order(&mut db, &order.uid).await.unwrap().unwrap(); - assert_eq!(read.valid_from, Some(1_700_000_000)); - - let full = single_full_order_with_quote(&mut db, &order.uid) - .await - .unwrap() - .unwrap() - .full_order; - assert_eq!(full.valid_from, Some(1_700_000_000)); - } - #[tokio::test] #[ignore] async fn postgres_orders_in_tx() { diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 98524f34e9..979ea20889 100644 --- a/crates/orderbook/src/database/orders.rs +++ b/crates/orderbook/src/database/orders.rs @@ -701,49 +701,6 @@ mod tests { std::sync::atomic::{AtomicI64, Ordering}, }; - // The DB->model read conversion preserves a concrete valid_from (i64 -> - // Option), the value surfaced on the order API responses. - #[test] - fn valid_from_survives_db_to_model_conversion() { - let full_order = FullOrder { - uid: ByteArray([0; 56]), - owner: ByteArray([0; 20]), - creation_timestamp: Utc::now(), - sell_token: ByteArray([1; 20]), - buy_token: ByteArray([2; 20]), - sell_amount: BigDecimal::from(1), - buy_amount: BigDecimal::from(1), - valid_to: (Utc::now() + Duration::days(1)).timestamp(), - valid_from: Some(1_700_000_000), - app_data: ByteArray([0; 32]), - fee_amount: BigDecimal::default(), - kind: DbOrderKind::Sell, - class: DbOrderClass::Liquidity, - partially_fillable: true, - signature: vec![0; 65], - receiver: None, - sum_sell: BigDecimal::default(), - sum_buy: BigDecimal::default(), - sum_fee: BigDecimal::default(), - invalidated: false, - signing_scheme: DbSigningScheme::Eip712, - settlement_contract: ByteArray([0; 20]), - sell_token_balance: DbSellTokenSource::External, - buy_token_balance: DbBuyTokenDestination::Internal, - presignature_pending: false, - pre_interactions: Vec::new(), - post_interactions: Vec::new(), - ethflow_data: None, - onchain_user: None, - onchain_placement_error: None, - executed_fee: Default::default(), - executed_fee_token: ByteArray([1; 20]), - full_app_data: Default::default(), - }; - let order = full_order_into_model_order(full_order).unwrap(); - assert_eq!(order.metadata.valid_from, Some(1_700_000_000)); - } - #[test] fn order_status() { let valid_to_timestamp = Utc::now() + Duration::days(1); diff --git a/crates/shared/src/order_validation.rs b/crates/shared/src/order_validation.rs index e9f8a0d7fb..a17a88295c 100644 --- a/crates/shared/src/order_validation.rs +++ b/crates/shared/src/order_validation.rs @@ -2184,62 +2184,6 @@ mod tests { assert!(matches!(result, Err(ValidationError::ZeroAmount))); } - #[tokio::test] - async fn post_validate_err_invalid_valid_from() { - let mut order_quoter = MockOrderQuoting::new(); - let mut balance_fetcher = MockBalanceFetching::new(); - order_quoter - .expect_find_quote() - .returning(|_, _| Ok(Default::default())); - balance_fetcher - .expect_can_transfer() - .returning(|_, _| Ok(())); - let mut limit_order_counter = MockLimitOrderCounting::new(); - limit_order_counter.expect_count().returning(|_| Ok(0u64)); - let native_token = WETH9::Instance::new([0xef; 20].into(), ethrpc::mock::web3().provider); - let validator = OrderValidator::new( - native_token, - Arc::new(order_validation::banned::Users::none()), - OrderValidPeriodConfiguration::any(), - false, - Default::default(), - HooksTrampoline::Instance::new( - Address::repeat_byte(0xcf), - ProviderBuilder::new() - .connect_mocked_client(Asserter::new()) - .erased(), - ), - Arc::new(order_quoter), - Arc::new(balance_fetcher), - Arc::new(MockSignatureValidating::new()), - None, - Arc::new(limit_order_counter), - 0, - Default::default(), - u64::MAX, - SameTokensPolicy::Disallow, - ); - // `validFrom == valid_to` makes the order impossible to ever be valid. - let valid_to = time::now_in_epoch_seconds() + 2; - let order = OrderCreation { - valid_to, - sell_token: Address::with_last_byte(1), - buy_token: Address::with_last_byte(2), - buy_amount: alloy::primitives::U256::from(1), - sell_amount: alloy::primitives::U256::from(1), - fee_amount: alloy::primitives::U256::from(0), - signature: Signature::Eip712(EcdsaSignature::non_zero()), - app_data: OrderCreationAppData::Full { - full: format!(r#"{{"metadata":{{"validFrom":{valid_to}}}}}"#), - }, - ..Default::default() - }; - let result = validator - .validate_and_construct_order(order, &Default::default(), Default::default(), None) - .await; - assert!(matches!(result, Err(ValidationError::InvalidValidFrom))); - } - #[tokio::test] async fn post_validate_err_wrong_owner() { let mut order_quoter = MockOrderQuoting::new(); From 644b9a97e6271755ca0437bdc1bd2cc83ea89270 Mon Sep 17 00:00:00 2001 From: Aryan Godara <65490434+AryanGodara@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:03:53 +0530 Subject: [PATCH 4/5] address claude code comments --- .../src/database/onchain_order_events/mod.rs | 106 ++++++++++-------- crates/shared/src/order_validation.rs | 6 + 2 files changed, 67 insertions(+), 45 deletions(-) diff --git a/crates/autopilot/src/database/onchain_order_events/mod.rs b/crates/autopilot/src/database/onchain_order_events/mod.rs index f8c54d7df2..4a37e2d7bc 100644 --- a/crates/autopilot/src/database/onchain_order_events/mod.rs +++ b/crates/autopilot/src/database/onchain_order_events/mod.rs @@ -10,7 +10,7 @@ use { rpc::types::Log, }, anyhow::{Context, Result, anyhow, bail}, - app_data::AppDataHash, + app_data::{AppDataHash, ProtocolAppData}, chrono::{TimeZone, Utc}, contracts::{ CoWSwapOnchainOrders::CoWSwapOnchainOrders::{ @@ -356,9 +356,9 @@ impl OnchainOrderParser { .await .context("appending quotes for onchain orders failed")?; - insert_order_hooks(transaction, &mut orders, &self.trampoline) + handle_app_data(transaction, &mut orders, &self.trampoline) .await - .context("failed to insert hooks")?; + .context("failed to handle app data")?; database::orders::insert_orders_and_ignore_conflicts(transaction, orders.as_slice()) .await @@ -628,7 +628,7 @@ fn convert_onchain_order_placement( true => OrderClass::Limit, false => OrderClass::Market, }, - // Backfilled from the order's app-data in `insert_order_hooks` before the + // Backfilled from the order's app-data in `handle_app_data` before the // order is persisted; the full app-data isn't available at this point. valid_from: None, }; @@ -683,29 +683,15 @@ fn extract_order_data_from_onchain_order_placement_event( Ok((order_data, owner, signing_scheme, order_uid)) } -async fn insert_order_hooks( +/// Populates all app-data-derived order fields before the orders are persisted: +/// backfills each order's `valid_from` and indexes its pre/post hook +/// interactions. Must run before the orders are inserted (it mutates them). +/// Orders whose app-data is unknown or unparseable are left unchanged. +async fn handle_app_data( db: &mut PgConnection, orders: &mut [Order], trampoline: &HooksTrampoline::Instance, ) -> Result<()> { - let mut interactions_to_insert = vec![]; - - let execute_via_trampoline = |hooks: Vec| { - trampoline - .execute( - hooks - .into_iter() - .map(|hook| Hook { - target: hook.target, - callData: alloy::primitives::Bytes::from(hook.call_data.clone()), - gasLimit: U256::from(hook.gas_limit), - }) - .collect(), - ) - .calldata() - .to_vec() - }; - for order in orders.iter_mut() { let appdata_json = database::app_data::fetch(db, &order.app_data) .await @@ -718,42 +704,72 @@ async fn insert_order_hooks( tracing::debug!(appdata = %String::from_utf8_lossy(&appdata_json), "could not parse appdata"); continue; }; - // Backfill the user-supplied valid_from from the app-data. + + store_hooks(db, order, &parsed, trampoline).await?; order.valid_from = parsed.valid_from.map(i64::from); - if parsed.hooks.pre.is_empty() && parsed.hooks.post.is_empty() { - continue; // no additional interactions to index - } + } + Ok(()) +} - let interactions_count = database::orders::next_free_interaction_indices(db, order.uid) - .await - .context("failed to fetch interaction count")?; +async fn store_hooks( + db: &mut PgConnection, + order: &Order, + parsed: &ProtocolAppData, + trampoline: &HooksTrampoline::Instance, +) -> Result<()> { + if parsed.hooks.pre.is_empty() && parsed.hooks.post.is_empty() { + return Ok(()); + } - if !parsed.hooks.pre.is_empty() { - let interaction = database::orders::Interaction { + let interactions_count = database::orders::next_free_interaction_indices(db, order.uid) + .await + .context("failed to fetch interaction count")?; + + let execute_via_trampoline = |hooks: &[app_data::Hook]| { + trampoline + .execute( + hooks + .iter() + .map(|hook| Hook { + target: hook.target, + callData: alloy::primitives::Bytes::from(hook.call_data.clone()), + gasLimit: U256::from(hook.gas_limit), + }) + .collect(), + ) + .calldata() + .to_vec() + }; + + let mut interactions = vec![]; + if !parsed.hooks.pre.is_empty() { + interactions.push(( + order.uid, + database::orders::Interaction { target: ByteArray(trampoline.address().0.0), value: 0.into(), - data: execute_via_trampoline(parsed.hooks.pre), + data: execute_via_trampoline(&parsed.hooks.pre), index: interactions_count.next_pre_interaction_index, execution: database::orders::ExecutionTime::Pre, - }; - interactions_to_insert.push((order.uid, interaction)); - } - - if !parsed.hooks.post.is_empty() { - let interaction = database::orders::Interaction { + }, + )); + } + if !parsed.hooks.post.is_empty() { + interactions.push(( + order.uid, + database::orders::Interaction { target: ByteArray(trampoline.address().0.0), value: 0.into(), - data: execute_via_trampoline(parsed.hooks.post), + data: execute_via_trampoline(&parsed.hooks.post), index: interactions_count.next_post_interaction_index, execution: database::orders::ExecutionTime::Post, - }; - interactions_to_insert.push((order.uid, interaction)); - } + }, + )); } - database::orders::insert_or_overwrite_interactions(db, &interactions_to_insert) + database::orders::insert_or_overwrite_interactions(db, &interactions) .await - .context("could not insert interactions for orders") + .context("could not insert interactions for order") } #[derive(prometheus_metric_storage::MetricStorage, Clone, Debug)] diff --git a/crates/shared/src/order_validation.rs b/crates/shared/src/order_validation.rs index a17a88295c..67aa9e591f 100644 --- a/crates/shared/src/order_validation.rs +++ b/crates/shared/src/order_validation.rs @@ -1024,6 +1024,12 @@ impl OrderValidating for OrderValidator { // A `valid_from` at or after `valid_to` makes the order impossible to ever // be valid. A `valid_from` in the past is allowed: the order is simply // eligible immediately. + // + // This only guards API-submitted orders. On-chain (e.g. ethflow) orders are + // already placed and cannot be rejected here; their `valid_from` is + // backfilled in `handle_app_data` without this check. A misconfigured + // `valid_from >= valid_to` there is intentionally accepted — it simply means + // the order never becomes solvable and expires unfilled. if app_data .inner .protocol From 1cdaace98e3f3241f5830d6c2abbb63b16270645 Mon Sep 17 00:00:00 2001 From: Aryan Godara <65490434+AryanGodara@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:50:55 +0530 Subject: [PATCH 5/5] address review comments --- crates/database/src/orders.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/database/src/orders.rs b/crates/database/src/orders.rs index 2fbe36c4ef..4b3b4572c9 100644 --- a/crates/database/src/orders.rs +++ b/crates/database/src/orders.rs @@ -866,15 +866,18 @@ pub fn open_orders_by_time_or_uids<'a>( after_timestamp: DateTime, now: i64, ) -> BoxStream<'a, Result> { - // Optimized version using the OPEN_ORDERS pattern with CTEs and LATERAL joins + // Optimized version using the OPEN_ORDERS pattern with CTEs and LATERAL joins. + // + // `selected_orders` has two branches: + // - Branch 1: the usual "changed since $1" set (created/cancelled since the + // checkpoint, or explicitly requested by uid), gated by `valid_from <= now`. + // - Branch 2: re-selects orders whose `valid_from` crossed since $1 -- becoming + // valid isn't a DB write, so branch 1 would never pick them up. #[rustfmt::skip] const QUERY: &str = r#" WITH selected_orders AS ( SELECT o.* FROM orders o - -- Branch 1 is the usual "changed since $1" set, gated by valid_from. Branch 2 - -- re-selects orders whose valid_from crossed since $1 -- becoming valid isn't a - -- DB write, so branch 1 would otherwise never pick them up. WHERE ( (o.creation_timestamp > $1 OR o.cancellation_timestamp > $1 OR o.uid = ANY($2)) AND (o.valid_from IS NULL OR o.valid_from <= $3) @@ -2154,7 +2157,10 @@ mod tests { crate::clear_DANGER_(&mut db).await.unwrap(); async fn solvable_uids(ex: &mut PgConnection, now: i64) -> HashSet { - solvable_orders(ex, 0, now) + // `min_valid_to = now` mirrors production, so `valid_to` expiry is + // enforced alongside `valid_from` gating (an order that already + // expired must not resurface just because `valid_from` crossed). + solvable_orders(ex, now, now) .map_ok(|o| o.uid) .try_collect() .await @@ -2166,7 +2172,9 @@ mod tests { kind: OrderKind::Sell, sell_amount: 10.into(), buy_amount: 100.into(), - valid_to: 100, + // Valid well past the checked `now` values, so the order is genuinely + // solvable once `valid_from` kicks in (`valid_from` < `valid_to`). + valid_to: 10_000, partially_fillable: true, creation_timestamp: Utc::now(), valid_from: Some(1_000), @@ -2227,7 +2235,10 @@ mod tests { kind: OrderKind::Sell, sell_amount: 10.into(), buy_amount: 100.into(), - valid_to: 100, + // Valid past valid_from so the order is genuinely solvable when it + // becomes eligible (valid_from < valid_to). The incremental query only + // selects candidates; the cache layer applies the valid_to filter. + valid_to: valid_from + 100, partially_fillable: true, // Created well before the checkpoint: branch 1 never picks it up. creation_timestamp: base - Duration::seconds(100),