From b5e9741e20a7618a3f14ed2b60b5810b858befb9 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Fri, 24 Jul 2026 14:59:16 +0000 Subject: [PATCH 1/9] correctly support validFrom --- .../src/database/onchain_order_events/mod.rs | 25 +++++++++++++++++- crates/database/src/orders.rs | 26 ++++++++++++++----- crates/model/src/order.rs | 4 +++ crates/orderbook/src/database/orders.rs | 3 +++ crates/orderbook/src/quoter.rs | 6 ----- crates/shared/src/db_order_conversions.rs | 1 + crates/shared/src/order_validation.rs | 6 +---- .../sql/V115__add_valid_from_to_orders.sql | 1 + .../sql/V116__index_valid_from_on_orders.sql | 2 ++ 9 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 database/sql/V115__add_valid_from_to_orders.sql create mode 100644 database/sql/V116__index_valid_from_on_orders.sql diff --git a/crates/autopilot/src/database/onchain_order_events/mod.rs b/crates/autopilot/src/database/onchain_order_events/mod.rs index 289ae0fd5e..f949a5ffd0 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?; @@ -360,6 +360,10 @@ impl OnchainOrderParser { .await .context("failed to insert hooks")?; + populate_valid_from(transaction, &mut orders) + .await + .context("failed to populate valid_from")?; + database::orders::insert_orders_and_ignore_conflicts(transaction, orders.as_slice()) .await .context("insert_orders failed")?; @@ -628,6 +632,7 @@ fn convert_onchain_order_placement( true => OrderClass::Limit, false => OrderClass::Market, }, + valid_from: None, }; let onchain_order_placement_event = OnchainOrderPlacement { order_uid: ByteArray(order_uid.0), @@ -751,6 +756,22 @@ async fn insert_order_hooks( .context("could not insert interactions for orders") } +async fn populate_valid_from(db: &mut PgConnection, orders: &mut [Order]) -> Result<()> { + 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 { + continue; + }; + let Ok(parsed) = app_data::parse(&appdata_json) else { + continue; + }; + order.valid_from = parsed.valid_from.map(|v| v as i64); + } + Ok(()) +} + #[derive(prometheus_metric_storage::MetricStorage, Clone, Debug)] #[metric(subsystem = "onchain_orders")] struct Metrics { @@ -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/database/src/orders.rs b/crates/database/src/orders.rs index 23d12ae1db..bc77e0c323 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) @@ -526,6 +530,7 @@ pub struct FullOrder { pub executed_fee: BigDecimal, pub executed_fee_token: Address, pub full_app_data: Option>, + pub valid_from: Option, } impl FullOrder { @@ -641,7 +646,8 @@ array(Select (p.target, p.value, p.data) from interactions p where p.order_uid = (SELECT onchain_o.placement_error from onchain_placed_orders onchain_o where onchain_o.uid = o.uid limit 1) as onchain_placement_error, COALESCE((SELECT SUM(executed_fee) FROM order_execution oe WHERE oe.order_uid = o.uid), 0) as executed_fee, COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid = o.uid LIMIT 1), o.sell_token) as executed_fee_token, -- TODO surplus token -(SELECT full_app_data FROM app_data ad WHERE o.app_data = ad.contract_app_data LIMIT 1) as full_app_data +(SELECT full_app_data FROM app_data ad WHERE o.app_data = ad.contract_app_data LIMIT 1) as full_app_data, +o.valid_from "#; pub const FROM: &str = "orders o"; @@ -752,6 +758,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 <= EXTRACT(EPOCH FROM NOW())::bigint) 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) @@ -810,7 +817,8 @@ pub fn solvable_orders( NULL AS onchain_placement_error, COALESCE(fee_agg.executed_fee,0) AS executed_fee, COALESCE(fee_agg.executed_fee_token, lo.sell_token) AS executed_fee_token, - ad.full_app_data + ad.full_app_data, + lo.valid_from FROM live_orders lo LEFT JOIN LATERAL ( SELECT NOT signed AS unsigned @@ -859,7 +867,12 @@ 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)) + WHERE (o.creation_timestamp > $1 OR o.cancellation_timestamp > $1 OR o.uid = ANY($2) + -- Pick up orders whose valid_from just crossed NOW() since the last update. + OR (o.valid_from IS NOT NULL + AND o.valid_from > EXTRACT(EPOCH FROM $1)::bigint + AND o.valid_from <= EXTRACT(EPOCH FROM NOW())::bigint)) + AND (o.valid_from IS NULL OR o.valid_from <= EXTRACT(EPOCH FROM NOW())::bigint) ), trades_agg AS ( SELECT t.order_uid, @@ -917,7 +930,8 @@ SELECT opo.onchain_placement_error, COALESCE(fee_agg.executed_fee,0) AS executed_fee, COALESCE(fee_agg.executed_fee_token, so.sell_token) AS executed_fee_token, - ad.full_app_data + ad.full_app_data, + so.valid_from FROM selected_orders so LEFT JOIN LATERAL ( SELECT NOT signed AS unsigned diff --git a/crates/model/src/order.rs b/crates/model/src/order.rs index 20508c154b..3c7e0227ee 100644 --- a/crates/model/src/order.rs +++ b/crates/model/src/order.rs @@ -731,6 +731,10 @@ pub struct OrderMetadata { /// quote data for reference. #[serde(default, skip_serializing_if = "Option::is_none")] pub quote: Option, + /// Earliest time (UNIX timestamp seconds) the order may enter a batch + /// auction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_from: Option, } /// OrderUid is 56 bytes. When hex encoded as 0x prefixes Json string it is 116. diff --git a/crates/orderbook/src/database/orders.rs b/crates/orderbook/src/database/orders.rs index 081336e3ed..9499253441 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(|v| v as i64), }; database::orders::insert_order(ex, &db_order) @@ -625,6 +626,7 @@ fn full_order_with_quote_into_model_order( quote: quote .map(|q| order_quote_into_model(q, status)) .transpose()?, + valid_from: order.valid_from.map(|v| v as u32), }; let data = OrderData { sell_token: Address::new(order.sell_token.0), @@ -732,6 +734,7 @@ mod tests { executed_fee: Default::default(), executed_fee_token: ByteArray([1; 20]), // TODO surplus token full_app_data: Default::default(), + valid_from: None, }; // Open - sell (filled - 0%) diff --git a/crates/orderbook/src/quoter.rs b/crates/orderbook/src/quoter.rs index 7c9344eb03..ee0b188b9b 100644 --- a/crates/orderbook/src/quoter.rs +++ b/crates/orderbook/src/quoter.rs @@ -276,12 +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 // early above). This is best-effort correlation, not a guarantee: if diff --git a/crates/shared/src/db_order_conversions.rs b/crates/shared/src/db_order_conversions.rs index 96e90bdf6d..8151431248 100644 --- a/crates/shared/src/db_order_conversions.rs +++ b/crates/shared/src/db_order_conversions.rs @@ -100,6 +100,7 @@ pub fn full_order_into_model_order(order: database::orders::FullOrder) -> Result .transpose() .context("full app data isn't utf-8")?, quote: None, + valid_from: order.valid_from.map(|v| v as u32), }; let data = OrderData { sell_token: Address::new(order.sell_token.0), diff --git a/crates/shared/src/order_validation.rs b/crates/shared/src/order_validation.rs index b1998093ee..e62159a2a3 100644 --- a/crates/shared/src/order_validation.rs +++ b/crates/shared/src/order_validation.rs @@ -765,11 +765,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 { @@ -1040,6 +1035,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/sql/V115__add_valid_from_to_orders.sql b/database/sql/V115__add_valid_from_to_orders.sql new file mode 100644 index 0000000000..f885e4fd3f --- /dev/null +++ b/database/sql/V115__add_valid_from_to_orders.sql @@ -0,0 +1 @@ +ALTER TABLE orders ADD COLUMN valid_from BIGINT; diff --git a/database/sql/V116__index_valid_from_on_orders.sql b/database/sql/V116__index_valid_from_on_orders.sql new file mode 100644 index 0000000000..9ed003adfb --- /dev/null +++ b/database/sql/V116__index_valid_from_on_orders.sql @@ -0,0 +1,2 @@ +CREATE INDEX CONCURRENTLY orders_valid_from_idx ON orders (valid_from) +WHERE valid_from IS NOT NULL; From 6895099858933206f0f92c6ad8068da06c3c26ff Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Fri, 24 Jul 2026 14:59:29 +0000 Subject: [PATCH 2/9] add e2e test --- crates/e2e/tests/e2e/main.rs | 1 + crates/e2e/tests/e2e/valid_from.rs | 97 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 crates/e2e/tests/e2e/valid_from.rs 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/valid_from.rs b/crates/e2e/tests/e2e/valid_from.rs new file mode 100644 index 0000000000..723a402c15 --- /dev/null +++ b/crates/e2e/tests/e2e/valid_from.rs @@ -0,0 +1,97 @@ +use { + ::alloy::providers::ext::AnvilApi, + e2e::setup::*, + ethrpc::{ + Web3, + alloy::{CallBuilderExt, EvmProviderExt}, + block_stream::timestamp_of_current_block_in_seconds, + }, + model::{ + order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus}, + signature::EcdsaSigningScheme, + }, + number::units::EthUnit, + std::time::Duration, +}; + +#[tokio::test] +#[ignore] +async fn local_node_valid_from() { + run_test(valid_from_test).await; +} + +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; + + let now = timestamp_of_current_block_in_seconds(&web3.provider) + .await + .unwrap(); + let valid_from = now + 30; + + let app_data = format!(r#"{{"metadata":{{"validFrom":{valid_from}}}}}"#); + let order = OrderCreation { + sell_token: *token_a.address(), + sell_amount: 5u64.eth(), + buy_token: *token_b.address(), + buy_amount: 1u64.eth(), + valid_to: now + 300, + kind: OrderKind::Sell, + app_data: OrderCreationAppData::Full { full: app_data }, + ..Default::default() + } + .sign( + EcdsaSigningScheme::Eip712, + &onchain.contracts().domain_separator, + &trader.signer, + ); + let uid = services.create_order(&order).await.unwrap(); + + // The order should not be settled while valid_from is in the future. + // Mine a few blocks and confirm it stays Open. + for _ in 0..5 { + onchain.mint_block().await; + tokio::time::sleep(Duration::from_millis(500)).await; + } + let status = services.get_order(&uid).await.unwrap().metadata.status; + assert_eq!( + status, + OrderStatus::Open, + "order should not be solvable before valid_from" + ); + + // Advance blockchain time past valid_from. + web3.provider + .evm_set_next_block_timestamp(valid_from as u64 + 5) + .await + .unwrap(); + web3.provider.evm_mine(None).await.unwrap(); + + // Now the order should be settled. + wait_for_condition(TIMEOUT, || async { + onchain.mint_block().await; + services + .get_order(&uid) + .await + .map(|o| o.metadata.status == OrderStatus::Fulfilled) + .unwrap_or(false) + }) + .await + .expect("order was not settled after valid_from elapsed"); +} From 4ce0e15e6fd4c20949d51858ab9b8d61bc07463a Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Mon, 27 Jul 2026 06:20:12 +0000 Subject: [PATCH 3/9] fixup jit order SELECT --- crates/database/src/jit_orders.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/database/src/jit_orders.rs b/crates/database/src/jit_orders.rs index b9d1fde168..9f0a44bd92 100644 --- a/crates/database/src/jit_orders.rs +++ b/crates/database/src/jit_orders.rs @@ -34,7 +34,8 @@ NULL AS onchain_user, NULL AS onchain_placement_error, COALESCE((SELECT SUM(executed_fee) FROM order_execution oe WHERE oe.order_uid = o.uid), 0) as executed_fee, COALESCE((SELECT executed_fee_token FROM order_execution oe WHERE oe.order_uid = o.uid LIMIT 1), o.sell_token) as executed_fee_token, -- TODO surplus token -NULL AS full_app_data +NULL AS full_app_data, +NULL as valid_from "#; pub const FROM: &str = "jit_orders o"; From e3145386dcbef621f222efa7bae8a9dd8bd55c34 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Mon, 27 Jul 2026 06:32:12 +0000 Subject: [PATCH 4/9] documentation --- database/README.md | 5 ++++- database/sql/V115__add_valid_from_to_orders.sql | 1 + database/sql/V116__index_valid_from_on_orders.sql | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/database/README.md b/database/README.md index 01063d9bb7..7833f7b04e 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 where the order may enter regular auctions. if this is NULL the order may be put into auctions immediately. Indexes: - PRIMARY KEY: btree(`uid`) @@ -281,6 +282,8 @@ Indexes: - orders\_true\_valid\_to: btree(`true_valid_to`) - 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 +- `orders_valid_from_idx`: btree(`valid_from`) WHERE valid_from IS NOT NULL + ### fee_policies diff --git a/database/sql/V115__add_valid_from_to_orders.sql b/database/sql/V115__add_valid_from_to_orders.sql index f885e4fd3f..12fba22f3b 100644 --- a/database/sql/V115__add_valid_from_to_orders.sql +++ b/database/sql/V115__add_valid_from_to_orders.sql @@ -1 +1,2 @@ +-- valid_from allows orders to be placed now and only become active in the future ALTER TABLE orders ADD COLUMN valid_from BIGINT; diff --git a/database/sql/V116__index_valid_from_on_orders.sql b/database/sql/V116__index_valid_from_on_orders.sql index 9ed003adfb..07f4ee366e 100644 --- a/database/sql/V116__index_valid_from_on_orders.sql +++ b/database/sql/V116__index_valid_from_on_orders.sql @@ -1,2 +1,5 @@ +-- index to quickly find orders that are already active. most orders are +-- expected to be active immediately so we use a partial index to keep it +-- small CREATE INDEX CONCURRENTLY orders_valid_from_idx ON orders (valid_from) WHERE valid_from IS NOT NULL; From f27ef386dbad133cbe212b2334ef4043b1395825 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Mon, 27 Jul 2026 06:34:17 +0000 Subject: [PATCH 5/9] adjust existing e2e test --- crates/e2e/tests/e2e/quote_fastpath_flags.rs | 32 +------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/crates/e2e/tests/e2e/quote_fastpath_flags.rs b/crates/e2e/tests/e2e/quote_fastpath_flags.rs index 1e173695af..045afaea4b 100644 --- a/crates/e2e/tests/e2e/quote_fastpath_flags.rs +++ b/crates/e2e/tests/e2e/quote_fastpath_flags.rs @@ -23,8 +23,7 @@ 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 `enableFastPath` app-data field. 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 +76,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 +111,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 - ); } From 4b2e0f37cc82369e5e683d564c254fd2cfb53b99 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Mon, 27 Jul 2026 07:21:58 +0000 Subject: [PATCH 6/9] fixup e2e test --- crates/e2e/tests/e2e/valid_from.rs | 60 +++++++++++------------------- 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/crates/e2e/tests/e2e/valid_from.rs b/crates/e2e/tests/e2e/valid_from.rs index 723a402c15..ea44ea66bc 100644 --- a/crates/e2e/tests/e2e/valid_from.rs +++ b/crates/e2e/tests/e2e/valid_from.rs @@ -1,11 +1,6 @@ use { - ::alloy::providers::ext::AnvilApi, e2e::setup::*, - ethrpc::{ - Web3, - alloy::{CallBuilderExt, EvmProviderExt}, - block_stream::timestamp_of_current_block_in_seconds, - }, + ethrpc::{Web3, alloy::CallBuilderExt}, model::{ order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus}, signature::EcdsaSigningScheme, @@ -40,10 +35,8 @@ async fn valid_from_test(web3: Web3) { let services = Services::new(&onchain).await; services.start_protocol(solver).await; - let now = timestamp_of_current_block_in_seconds(&web3.provider) - .await - .unwrap(); - let valid_from = now + 30; + let now = model::time::now_in_epoch_seconds(); + let valid_from = now + 3; let app_data = format!(r#"{{"metadata":{{"validFrom":{valid_from}}}}}"#); let order = OrderCreation { @@ -63,35 +56,24 @@ async fn valid_from_test(web3: Web3) { ); let uid = services.create_order(&order).await.unwrap(); - // The order should not be settled while valid_from is in the future. - // Mine a few blocks and confirm it stays Open. - for _ in 0..5 { - onchain.mint_block().await; - tokio::time::sleep(Duration::from_millis(500)).await; - } - let status = services.get_order(&uid).await.unwrap().metadata.status; - assert_eq!( - status, - OrderStatus::Open, - "order should not be solvable before valid_from" - ); - - // Advance blockchain time past valid_from. - web3.provider - .evm_set_next_block_timestamp(valid_from as u64 + 5) - .await - .unwrap(); - web3.provider.evm_mine(None).await.unwrap(); - - // Now the order should be settled. - wait_for_condition(TIMEOUT, || async { - onchain.mint_block().await; - services - .get_order(&uid) - .await - .map(|o| o.metadata.status == OrderStatus::Fulfilled) - .unwrap_or(false) + tokio::time::timeout(TIMEOUT, async { + loop { + onchain.mint_block().await; + let status = services.get_order(&uid).await.unwrap().metadata.status; + let now_in_unix = model::time::now_in_epoch_seconds(); + if now_in_unix < valid_from { + assert_eq!(status, OrderStatus::Open); + } else if now_in_unix > valid_from + 1 { + assert_eq!(status, OrderStatus::Fulfilled); + break; + } else { + // during the time [valid_from..=valid_from + 1] we don't assert + // anything about the order status so that race conditions don't + // cause assertions to fail + } + tokio::time::sleep(Duration::from_millis(200)).await; + } }) .await - .expect("order was not settled after valid_from elapsed"); + .unwrap(); } From b2da7e7e0fe05ecb1e13e9be2c896b645e08982d Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Mon, 27 Jul 2026 13:51:26 +0000 Subject: [PATCH 7/9] assert valid_from works on ethflow orders --- crates/e2e/tests/e2e/valid_from.rs | 59 +++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/e2e/tests/e2e/valid_from.rs b/crates/e2e/tests/e2e/valid_from.rs index ea44ea66bc..5764831e74 100644 --- a/crates/e2e/tests/e2e/valid_from.rs +++ b/crates/e2e/tests/e2e/valid_from.rs @@ -1,8 +1,11 @@ use { + crate::ethflow::ExtendedEthFlowOrder, + ::alloy::primitives::{Address, U256}, + contracts::CoWSwapEthFlow, e2e::setup::*, ethrpc::{Web3, alloy::CallBuilderExt}, model::{ - order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus}, + order::{OrderCreation, OrderCreationAppData, OrderKind, OrderStatus, OrderUid}, signature::EcdsaSigningScheme, }, number::units::EthUnit, @@ -56,20 +59,64 @@ async fn valid_from_test(web3: Web3) { ); let uid = services.create_order(&order).await.unwrap(); + order_filled_after_valid_from(&onchain, &services, uid, valid_from).await; + + // Now do the same for an ethflow order to verify that `validFrom` is also + // honored when it comes in via the app data attached to an on-chain order. + let now = model::time::now_in_epoch_seconds(); + let ethflow_valid_from = now + 3; + 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: now + 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; + + order_filled_after_valid_from(&onchain, &services, ethflow_uid, ethflow_valid_from).await; +} + +async fn order_filled_after_valid_from( + onchain: &OnchainComponents, + services: &Services<'_>, + order_uid: OrderUid, + valid_from: u32, +) { tokio::time::timeout(TIMEOUT, async { loop { onchain.mint_block().await; - let status = services.get_order(&uid).await.unwrap().metadata.status; let now_in_unix = model::time::now_in_epoch_seconds(); + let Ok(order) = services.get_order(&order_uid).await else { + tokio::time::sleep(Duration::from_millis(200)).await; + continue; + }; + let status = order.metadata.status; if now_in_unix < valid_from { assert_eq!(status, OrderStatus::Open); } else if now_in_unix > valid_from + 1 { assert_eq!(status, OrderStatus::Fulfilled); break; - } else { - // during the time [valid_from..=valid_from + 1] we don't assert - // anything about the order status so that race conditions don't - // cause assertions to fail } tokio::time::sleep(Duration::from_millis(200)).await; } From 65232e2c1f9122fda53f47d1a08c49c0695f226b Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Tue, 28 Jul 2026 07:21:58 +0000 Subject: [PATCH 8/9] clean up appdata handling in ethflow orders --- .../src/database/onchain_order_events/mod.rs | 120 +++++++++--------- 1 file changed, 57 insertions(+), 63 deletions(-) diff --git a/crates/autopilot/src/database/onchain_order_events/mod.rs b/crates/autopilot/src/database/onchain_order_events/mod.rs index f949a5ffd0..3e7e5f2471 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,13 +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")?; - - populate_valid_from(transaction, &mut orders) - .await - .context("failed to populate valid_from")?; + .context("failed to handle app data")?; database::orders::insert_orders_and_ignore_conflicts(transaction, orders.as_slice()) .await @@ -685,18 +681,49 @@ fn extract_order_data_from_onchain_order_placement_event( Ok((order_data, owner, signing_scheme, order_uid)) } -async fn insert_order_hooks( +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 { + 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(|v| v as i64); + } + 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()), @@ -708,68 +735,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") -} - -async fn populate_valid_from(db: &mut PgConnection, orders: &mut [Order]) -> Result<()> { - 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 { - continue; - }; - let Ok(parsed) = app_data::parse(&appdata_json) else { - continue; - }; - order.valid_from = parsed.valid_from.map(|v| v as i64); - } - Ok(()) + .context("could not insert interactions for order") } #[derive(prometheus_metric_storage::MetricStorage, Clone, Debug)] From ba32090eae00b0d01d34e57776f9d3d2775ce50e Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Tue, 28 Jul 2026 07:30:24 +0000 Subject: [PATCH 9/9] openapi doc --- crates/model/src/order.rs | 4 ++-- crates/orderbook/openapi.yml | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/model/src/order.rs b/crates/model/src/order.rs index 3c7e0227ee..abd7a600c1 100644 --- a/crates/model/src/order.rs +++ b/crates/model/src/order.rs @@ -731,8 +731,8 @@ pub struct OrderMetadata { /// quote data for reference. #[serde(default, skip_serializing_if = "Option::is_none")] pub quote: Option, - /// Earliest time (UNIX timestamp seconds) the order may enter a batch - /// auction. + /// Earliest time (UNIX timestamp seconds) the order may enter a regular + /// batch auction. #[serde(default, skip_serializing_if = "Option::is_none")] pub valid_from: Option, } diff --git a/crates/orderbook/openapi.yml b/crates/orderbook/openapi.yml index dd97064c29..2e2d956c9e 100644 --- a/crates/orderbook/openapi.yml +++ b/crates/orderbook/openapi.yml @@ -1424,6 +1424,13 @@ components: allOf: - $ref: "#/components/schemas/StoredOrderQuote" nullable: true + validFrom: + description: > + Earliest UNIX timestamp when the order may be put into a regular batch + auction. If this is not set the order is allowed to be in regular auctions + immediately after creation. + type: number + nullable: true required: - creationDate - class