diff --git a/crates/configs/src/orderbook/mod.rs b/crates/configs/src/orderbook/mod.rs index 3f063ebfd6..1023505c9e 100644 --- a/crates/configs/src/orderbook/mod.rs +++ b/crates/configs/src/orderbook/mod.rs @@ -354,6 +354,7 @@ mod tests { max_limit_orders_per_user: 5, max_gas_per_order: 6_000_000, same_tokens_policy: SameTokensPolicy::AllowSell, + min_fast_path_exclusivity: Some(Duration::from_secs(30)), }, ipfs: Some(IpfsConfig { gateway: "https://gateway.pinata.cloud/ipfs/".parse().unwrap(), diff --git a/crates/configs/src/orderbook/order_validation.rs b/crates/configs/src/orderbook/order_validation.rs index f9244a8f0f..b20fcb7a64 100644 --- a/crates/configs/src/orderbook/order_validation.rs +++ b/crates/configs/src/orderbook/order_validation.rs @@ -75,6 +75,13 @@ pub struct OrderValidationConfig { /// Policy for orders where the buy and sell tokens are equal. #[serde(default)] pub same_tokens_policy: SameTokensPolicy, + + /// How long a fast-path order is held out of the batch auction for its + /// exclusive settlement (`valid_from = now + this`, unless the user set a + /// later one). Unset disables the fast path: orders requesting it are + /// rejected. + #[serde(with = "humantime_serde", default)] + pub min_fast_path_exclusivity: Option, } impl Default for OrderValidationConfig { @@ -86,6 +93,7 @@ impl Default for OrderValidationConfig { max_limit_orders_per_user: default_max_limit_orders_per_user(), max_gas_per_order: default_max_gas_per_order(), same_tokens_policy: Default::default(), + min_fast_path_exclusivity: None, } } } @@ -136,6 +144,7 @@ mod tests { max_limit_orders_per_user: 5, max_gas_per_order: 5_000_000, same_tokens_policy: SameTokensPolicy::AllowSell, + min_fast_path_exclusivity: Some(Duration::from_secs(30)), }; let serialized = toml::to_string_pretty(&config).unwrap(); diff --git a/crates/e2e/tests/e2e/quote_fastpath_flags.rs b/crates/e2e/tests/e2e/quote_fastpath_flags.rs index f8230ff233..226f5f8ae6 100644 --- a/crates/e2e/tests/e2e/quote_fastpath_flags.rs +++ b/crates/e2e/tests/e2e/quote_fastpath_flags.rs @@ -22,8 +22,8 @@ async fn local_node_fast_path_flags_rejected() { run_test(fast_path_flags_rejected).await; } -/// Verifies the orderbook rejects the not-yet-supported `fast_path` quote flag -/// and `enableFastPath` app-data field. +/// Verifies the orderbook rejects the `fast_path` quote flag and +/// `enableFastPath` app-data field when the fast path is not enabled by config. async fn fast_path_flags_rejected(web3: Web3) { let mut onchain = OnchainComponents::deploy(web3).await; let [trader] = onchain.make_accounts(1u64.eth()).await; @@ -71,8 +71,8 @@ async fn fast_path_flags_rejected(web3: Web3) { .unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!( - err.1.contains("enableFastPath"), - "error body should mention enableFastPath, got: {}", + err.1.contains("FastPathDisabled"), + "error body should mention FastPathDisabled, got: {}", err.1 ); @@ -107,8 +107,8 @@ async fn fast_path_flags_rejected(web3: Web3) { .unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!( - err.1.contains("enableFastPath"), - "error body should mention enableFastPath, got: {}", + err.1.contains("FastPathDisabled"), + "error body should mention FastPathDisabled, got: {}", err.1 ); } diff --git a/crates/orderbook/src/api/post_order.rs b/crates/orderbook/src/api/post_order.rs index 02c927dfbc..a9358f0eea 100644 --- a/crates/orderbook/src/api/post_order.rs +++ b/crates/orderbook/src/api/post_order.rs @@ -134,6 +134,14 @@ impl IntoResponse for AppDataValidationErrorWrapper { ), ) .into_response(), + AppDataValidationError::FastPathDisabled => ( + StatusCode::BAD_REQUEST, + error( + "FastPathDisabled", + "the fast path is not enabled on this environment.", + ), + ) + .into_response(), } } } @@ -259,6 +267,14 @@ impl IntoResponse for ValidationErrorWrapper { ), ) .into_response(), + ValidationError::FastPathDisabled => ( + StatusCode::BAD_REQUEST, + error( + "FastPathDisabled", + "the fast path is not enabled on this environment.", + ), + ) + .into_response(), ValidationError::IncompatibleSigningScheme => ( StatusCode::BAD_REQUEST, error( diff --git a/crates/orderbook/src/quoter.rs b/crates/orderbook/src/quoter.rs index e793e27931..f4c73cf2f3 100644 --- a/crates/orderbook/src/quoter.rs +++ b/crates/orderbook/src/quoter.rs @@ -275,12 +275,6 @@ impl QuoteHandler { let valid_to = order.valid_to; self.order_validator.partial_validate(order).await?; - if app_data.inner.protocol.enable_fast_path { - return Err(OrderQuoteError::AppData(AppDataValidationError::Invalid( - anyhow::anyhow!("'enableFastPath' 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/orderbook/src/run.rs b/crates/orderbook/src/run.rs index 9749c3d7af..17e6574547 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -400,34 +400,37 @@ pub async fn run(config: Configuration) { } }); - let order_validator = Arc::new(OrderValidator::new( - native_token.clone(), - Arc::new(order_validation::banned::Users::new( - chainalysis_oracle, - config.banned_users.hermod.clone().map(|hermod| { - order_validation::banned::HermodConfig { - url: hermod.url, - hmac_key: hermod.hmac_key, - api_key: hermod.api_key, - } - }), - config.banned_users.addresses, - config.banned_users.max_cache_size.get().to_u64().unwrap(), - )), - validity_configuration, - config.eip1271_skip_creation_validation, - deny_listed_tokens.clone(), - hooks_contract, - optimal_quoter.clone(), - balance_fetcher, - signature_validator, - validator_simulator, - Arc::new(postgres_write.clone()), - config.order_validation.max_limit_orders_per_user, - app_data_validator.clone(), - config.order_validation.max_gas_per_order, - config.order_validation.same_tokens_policy, - )); + let order_validator = Arc::new( + OrderValidator::new( + native_token.clone(), + Arc::new(order_validation::banned::Users::new( + chainalysis_oracle, + config.banned_users.hermod.clone().map(|hermod| { + order_validation::banned::HermodConfig { + url: hermod.url, + hmac_key: hermod.hmac_key, + api_key: hermod.api_key, + } + }), + config.banned_users.addresses, + config.banned_users.max_cache_size.get().to_u64().unwrap(), + )), + validity_configuration, + config.eip1271_skip_creation_validation, + deny_listed_tokens.clone(), + hooks_contract, + optimal_quoter.clone(), + balance_fetcher, + signature_validator, + validator_simulator, + Arc::new(postgres_write.clone()), + config.order_validation.max_limit_orders_per_user, + app_data_validator.clone(), + config.order_validation.max_gas_per_order, + config.order_validation.same_tokens_policy, + ) + .with_min_fast_path_exclusivity(config.order_validation.min_fast_path_exclusivity), + ); let ipfs = config .ipfs .map(|ipfs| { diff --git a/crates/shared/src/order_validation.rs b/crates/shared/src/order_validation.rs index 5815e40b90..f503f82616 100644 --- a/crates/shared/src/order_validation.rs +++ b/crates/shared/src/order_validation.rs @@ -248,6 +248,9 @@ pub enum AppDataValidationError { actual: AppDataHash, }, Invalid(anyhow::Error), + /// The order opts into the fast path but it is not enabled on this + /// environment. + FastPathDisabled, } #[derive(Debug)] @@ -278,6 +281,9 @@ pub enum ValidationError { /// `valid_from` leaves too small a window before `valid_to` for the order /// to be settled. InvalidValidFrom, + /// The order opts into the fast path but it is not enabled on this + /// environment. + FastPathDisabled, IncompatibleSigningScheme, TooManyLimitOrders, TooMuchGas, @@ -389,6 +395,9 @@ pub struct OrderValidator { app_data_validator: Validator, max_gas_per_order: u64, same_tokens_policy: SameTokensPolicy, + /// How long a fast-path order is held out of the auction (its `valid_from` + /// is set to `now + this` on placement). `None` disables the fast path. + min_fast_path_exclusivity: Option, } #[derive(Debug, Eq, PartialEq, Default)] @@ -476,9 +485,17 @@ impl OrderValidator { app_data_validator, max_gas_per_order, same_tokens_policy, + min_fast_path_exclusivity: None, } } + /// Sets how long fast-path orders are held out of the auction. `None` (the + /// default) disables the fast path. + pub fn with_min_fast_path_exclusivity(mut self, exclusivity: Option) -> Self { + self.min_fast_path_exclusivity = exclusivity; + self + } + async fn check_max_limit_orders(&self, owner: Address) -> Result<(), ValidationError> { let num_limit_orders = self .limit_order_counter @@ -772,11 +789,10 @@ impl OrderValidating for OrderValidator { OrderCreationAppData::Full { full } => validate(full)?, }; - if app_data.protocol.enable_fast_path { - return Err(AppDataValidationError::Invalid(anyhow::anyhow!( - "'enableFastPath' is not yet supported" - ))); + if app_data.protocol.enable_fast_path && self.min_fast_path_exclusivity.is_none() { + return Err(AppDataValidationError::FastPathDisabled); } + let interactions = self.custom_interactions(&app_data.protocol.hooks); Ok(OrderAppData { @@ -1033,7 +1049,20 @@ impl OrderValidating for OrderValidator { return Err(ValidationError::TooMuchGas); } - if let Some(valid_from) = app_data.inner.protocol.valid_from { + let valid_from = if app_data.inner.protocol.enable_fast_path { + let Some(exclusivity) = self.min_fast_path_exclusivity else { + return Err(ValidationError::FastPathDisabled); + }; + app_data + .inner + .protocol + .valid_from + .or_else(|| Some(time::now_in_epoch_seconds() + exclusivity.as_secs() as u32)) + } else { + app_data.inner.protocol.valid_from + }; + + if let Some(valid_from) = valid_from { let min = self.validity_configuration.min.as_secs(); if u64::from(data.valid_to) < u64::from(valid_from) + min { return Err(ValidationError::InvalidValidFrom); @@ -1057,7 +1086,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, + valid_from, ..Default::default() }, signature: order.signature.clone(), @@ -1776,48 +1805,53 @@ mod tests { #[tokio::test] async fn enforces_minimum_validity_window() { - 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 signature_validating = MockSignatureValidating::new(); - signature_validating - .expect_validate_signature_and_get_additional_gas() - .never(); - let hooks = HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), - ProviderBuilder::new() - .connect_mocked_client(Asserter::new()) - .erased(), - ); - 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 { - min: Duration::from_secs(60), - max_market: Duration::from_secs(100), - max_limit: Duration::from_secs(200), - }, - false, - Default::default(), - hooks, - Arc::new(order_quoter), - Arc::new(balance_fetcher), - Arc::new(signature_validating), - None, - Arc::new(limit_order_counter), - 1, - Default::default(), - u64::MAX, - SameTokensPolicy::Disallow, - ); + let build_validator = |exclusivity: Option| { + let mut order_quoter = MockOrderQuoting::new(); + order_quoter + .expect_find_quote() + .returning(|_, _| Ok(Default::default())); + let mut balance_fetcher = MockBalanceFetching::new(); + balance_fetcher + .expect_can_transfer() + .returning(|_, _| Ok(())); + let mut signature_validating = MockSignatureValidating::new(); + signature_validating + .expect_validate_signature_and_get_additional_gas() + .never(); + let hooks = HooksTrampoline::Instance::new( + Address::from([0xcf; 20]), + ProviderBuilder::new() + .connect_mocked_client(Asserter::new()) + .erased(), + ); + 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); + OrderValidator::new( + native_token, + Arc::new(order_validation::banned::Users::none()), + OrderValidPeriodConfiguration { + min: Duration::from_secs(60), + max_market: Duration::from_secs(100), + max_limit: Duration::from_secs(200), + }, + false, + Default::default(), + hooks, + Arc::new(order_quoter), + Arc::new(balance_fetcher), + Arc::new(signature_validating), + None, + Arc::new(limit_order_counter), + 1, + Default::default(), + u64::MAX, + SameTokensPolicy::Disallow, + ) + .with_min_fast_path_exclusivity(exclusivity) + }; + let validator = build_validator(Some(Duration::from_secs(30))); let now = time::now_in_epoch_seconds(); let plain = |valid_to: u32| OrderCreation { @@ -1866,6 +1900,47 @@ mod tests { Err(ValidationError::InvalidValidFrom) ); validate(delayed(now + 50, now + 150)).await.unwrap(); + + let fast_path = |valid_to: u32| OrderCreation { + app_data: OrderCreationAppData::Full { + full: json!({ "metadata": { "enableFastPath": true } }).to_string(), + }, + ..plain(valid_to) + }; + std::assert_matches!( + validate(fast_path(now + 60)).await, + Err(ValidationError::InvalidValidFrom) + ); + let (order, _) = validate(fast_path(now + 150)).await.unwrap(); + let valid_from = order.metadata.valid_from.unwrap(); + assert!((now + 30..=now + 32).contains(&valid_from)); + + // A user-provided `validFrom` on a fast-path order is preserved. + let fast_path_user = OrderCreation { + app_data: OrderCreationAppData::Full { + full: json!({ "metadata": { "enableFastPath": true, "validFrom": now + 100 } }) + .to_string(), + }, + ..plain(now + 180) + }; + let (order, _) = validate(fast_path_user).await.unwrap(); + assert_eq!(order.metadata.valid_from, Some(now + 100)); + + // Fast-path orders are rejected when the fast path is disabled. + let disabled = build_validator(None); + std::assert_matches!( + disabled + .validate_and_construct_order( + fast_path(now + 150), + &Default::default(), + Default::default(), + None, + ) + .await, + Err(ValidationError::AppData( + AppDataValidationError::FastPathDisabled + )) + ); } #[tokio::test]