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..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::{ @@ -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,9 +356,9 @@ impl OnchainOrderParser { .await .context("appending quotes for onchain orders failed")?; - insert_order_hooks(transaction, &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,6 +628,9 @@ fn convert_onchain_order_placement( true => OrderClass::Limit, false => OrderClass::Market, }, + // 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, }; let onchain_order_placement_event = OnchainOrderPlacement { order_uid: ByteArray(order_uid.0), @@ -680,18 +683,53 @@ 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: &[Order], + orders: &mut [Order], trampoline: &HooksTrampoline::Instance, ) -> Result<()> { - let mut interactions_to_insert = vec![]; + for order in orders.iter_mut() { + let appdata_json = database::app_data::fetch(db, &order.app_data) + .await + .context("failed to fetch appdata")?; + let Some(appdata_json) = appdata_json else { + tracing::debug!(order = ?order.uid, "appdata for order is unknown"); + continue; + }; + let Ok(parsed) = app_data::parse(&appdata_json) else { + tracing::debug!(appdata = %String::from_utf8_lossy(&appdata_json), "could not parse appdata"); + continue; + }; - let execute_via_trampoline = |hooks: Vec| { + store_hooks(db, order, &parsed, trampoline).await?; + order.valid_from = parsed.valid_from.map(i64::from); + } + Ok(()) +} + +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(()); + } + + 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 - .into_iter() + .iter() .map(|hook| Hook { target: hook.target, callData: alloy::primitives::Bytes::from(hook.call_data.clone()), @@ -703,52 +741,35 @@ async fn insert_order_hooks( .to_vec() }; - for order in orders { - let appdata_json = database::app_data::fetch(db, &order.app_data) - .await - .context("failed to fetch appdata")?; - let Some(appdata_json) = appdata_json else { - tracing::debug!(order = ?order.uid, "appdata for order is unknown"); - continue; - }; - let Ok(parsed) = app_data::parse(&appdata_json) else { - tracing::debug!(appdata = %String::from_utf8_lossy(&appdata_json), "could not parse appdata"); - continue; - }; - if parsed.hooks.pre.is_empty() && parsed.hooks.post.is_empty() { - continue; // no additional interactions to index - } - - let interactions_count = database::orders::next_free_interaction_indices(db, order.uid) - .await - .context("failed to fetch interaction count")?; - - if !parsed.hooks.pre.is_empty() { - let interaction = database::orders::Interaction { + 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)] @@ -1025,6 +1046,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,6 +1160,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); 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..4b3b4572c9 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,14 +864,27 @@ 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 + // 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 - WHERE (o.creation_timestamp > $1 OR o.cancellation_timestamp > $1 OR o.uid = ANY($2)) + 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 +904,7 @@ SELECT so.sell_amount, so.buy_amount, so.valid_to, + so.valid_from, so.app_data, so.fee_amount, so.kind, @@ -953,6 +979,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 +1849,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 +1959,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 +2071,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 +2149,117 @@ 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 { + // `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 + .unwrap() + } + + let gated = Order { + uid: ByteArray([1u8; 56]), + kind: OrderKind::Sell, + sell_amount: 10.into(), + buy_amount: 100.into(), + // 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), + ..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]); + + 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 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), + 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] + ); + } + #[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..e039d6b4c6 --- /dev/null +++ b/crates/e2e/tests/e2e/valid_from.rs @@ -0,0 +1,129 @@ +use { + crate::ethflow::ExtendedEthFlowOrder, + ::alloy::primitives::{Address, U256}, + contracts::CoWSwapEthFlow, + e2e::setup::*, + ethrpc::{Web3, alloy::CallBuilderExt}, + model::{ + order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus, OrderUid}, + signature::EcdsaSigningScheme, + }, + number::units::EthUnit, +}; + +/// 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() { + run_test(valid_from_test).await; +} + +/// `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(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 + .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; + + // 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: 5u64.eth(), + buy_token: *token_b.address(), + buy_amount: 1u64.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 uid = services.create_order(&order).await.unwrap(); + settles_only_after_valid_from(&onchain, &services, uid, valid_from).await; + + // 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(); + + 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; +} + +/// 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 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/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 6caa18c13a..3c636fed5b 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1412,6 +1412,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..979ea20889 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()?, @@ -708,6 +714,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 6b89021ba8..4ffd0b2322 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..67aa9e591f 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,24 @@ 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. + // + // 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 + .valid_from + .is_some_and(|valid_from| valid_from >= data.valid_to) + { + return Err(ValidationError::InvalidValidFrom); + } + let order = Order { metadata: OrderMetadata { owner, @@ -1040,6 +1056,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(), diff --git a/database/README.md b/database/README.md index 01063d9bb7..14d6c3e762 100644 --- a/database/README.md +++ b/database/README.md @@ -267,7 +267,8 @@ 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: - 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;