diff --git a/crates/boundless-market/src/prover_utils/mod.rs b/crates/boundless-market/src/prover_utils/mod.rs index f71c4d90d..88b91cd25 100644 --- a/crates/boundless-market/src/prover_utils/mod.rs +++ b/crates/boundless-market/src/prover_utils/mod.rs @@ -192,6 +192,9 @@ pub struct OrderRequest { pub journal_bytes: Option, pub target_timestamp: Option, pub expire_timestamp: Option, + /// Total gas units (lock + fulfill) estimated during pricing. Stored so the pre-lock + /// check can simply re-multiply by the current gas price instead of re-computing. + pub gas_estimate: Option, pub expected_reward_eth: Option, #[serde(skip)] cached_id: OnceLock, @@ -217,6 +220,7 @@ impl OrderRequest { journal_bytes: None, target_timestamp: None, expire_timestamp: None, + gas_estimate: None, expected_reward_eth: None, cached_id: OnceLock::new(), } @@ -717,6 +721,10 @@ pub trait OrderPricingContext { .await?, ) }; + // Store the gas estimate on the order so the pre-lock check can re-use it + // instead of re-computing. + order.gas_estimate = Some(order_gas.saturating_to::()); + let mut order_gas_cost = U256::from(gas_price) * order_gas; tracing::debug!( "Estimated {order_gas} gas to {} order {order_id}; {} ether @ {} gwei", diff --git a/crates/broker/src/order_monitor.rs b/crates/broker/src/order_monitor.rs index 7ff0176bf..4b0f71c84 100644 --- a/crates/broker/src/order_monitor.rs +++ b/crates/broker/src/order_monitor.rs @@ -71,6 +71,10 @@ pub enum OrderMonitorErr { #[error("{code} Unexpected error: {0:?}", code = self.code())] UnexpectedError(#[from] anyhow::Error), + + /// Pre-lock gas check failed (e.g. gas spike). Caller may retry next block. + #[error("{code} Pre-lock gas check failed (retry later): {0}", code = self.code())] + PreLockCheckRetry(String), } impl_coded_debug!(OrderMonitorErr); @@ -83,6 +87,7 @@ impl CodedError for OrderMonitorErr { OrderMonitorErr::AlreadyLocked => "[B-OM-009]", OrderMonitorErr::InsufficientBalance => "[B-OM-010]", OrderMonitorErr::RpcErr(_) => "[B-OM-011]", + OrderMonitorErr::PreLockCheckRetry(_) => "[B-OM-012]", OrderMonitorErr::UnexpectedError(_) => "[B-OM-500]", } } @@ -283,6 +288,32 @@ where request_id, order.request.offer.lockCollateral ); + + // Pre-lock gas profitability check: compare gas cost against the order's current + // reward on the pricing ramp. Retries next block on failure (e.g. gas spike). + if let Some(gas_estimate) = order.gas_estimate { + let gas_price = self + .chain_monitor + .current_gas_price() + .await + .map_err(|e| OrderMonitorErr::PreLockCheckRetry(format!("gas price: {e:#}")))?; + let gas_cost = U256::from(gas_price) * U256::from(gas_estimate); + let reward = order + .request + .offer + .price_at(now_timestamp()) + .map_err(|e| OrderMonitorErr::PreLockCheckRetry(e.to_string()))?; + if gas_cost > reward { + let msg = format!( + "gas cost {} exceeds reward {}", + format_ether(gas_cost), + format_ether(reward) + ); + tracing::warn!("Pre-lock check failed for request 0x{:x}: {msg}", request_id); + return Err(OrderMonitorErr::PreLockCheckRetry(msg)); + } + } + let lock_block = self.market.lock_request(&order.request, order.client_sig.clone()).await.map_err( |e| -> OrderMonitorErr { @@ -567,6 +598,7 @@ where } let request_id = order.request.id; + let mut should_invalidate = true; match self.lock_order(order).await { Ok(lock_price) => { tracing::info!("Locked request: 0x{:x}", request_id); @@ -579,32 +611,24 @@ where } } Err(ref err) => { - match err { - OrderMonitorErr::UnexpectedError(inner) => { - tracing::error!( - "Failed to lock order: {order_id} - {} - {inner:?}", - err.code() - ); - } - OrderMonitorErr::AlreadyLocked => { - // For order already locked, we don't need to print the error backtrace. - tracing::warn!("Soft failed to lock request: {order_id} - {}", err.code()); + if let OrderMonitorErr::PreLockCheckRetry(reason) = err { + tracing::warn!("Pre-lock check failed for {order_id}: {reason}, will retry next block"); + should_invalidate = false; + } else { + if matches!(err, OrderMonitorErr::UnexpectedError(_)) { + tracing::error!("Failed to lock order: {order_id} - {err:?}"); + } else { + tracing::warn!("Failed to lock order: {order_id} - {err:?}"); } - _ => { - tracing::warn!( - "Soft failed to lock request: {order_id} - {} - {err:?}", - err.code() - ); + if let Err(e) = self.db.insert_skipped_request(order).await { + tracing::error!("Failed to set DB failure state for order: {order_id} - {e:?}"); } } - if let Err(err) = self.db.insert_skipped_request(order).await { - tracing::error!( - "Failed to set DB failure state for order: {order_id} - {err:?}" - ); - } } } - self.lock_and_prove_cache.invalidate(&order_id).await; + if should_invalidate { + self.lock_and_prove_cache.invalidate(&order_id).await; + } } else { if self.listen_only { tracing::info!( @@ -630,6 +654,16 @@ where Ok(()) } + #[cfg(test)] + pub(crate) async fn test_insert_into_lock_cache(&self, order: Arc) { + self.lock_and_prove_cache.insert(order.id().clone(), order).await; + } + + #[cfg(test)] + pub(crate) async fn test_lock_cache_contains(&self, order_id: &str) -> bool { + self.lock_and_prove_cache.get(order_id).await.is_some() + } + /// Calculate the gas units needed for an order and the corresponding cost in wei async fn calculate_order_gas_cost_wei( &self, @@ -1101,7 +1135,6 @@ pub(crate) mod tests { pub anvil: AnvilInstance, pub db: DbObj, pub market_address: Address, - #[allow(dead_code)] pub config: ConfigLock, pub priced_order_tx: mpsc::Sender>, pub signer: PrivateKeySigner, @@ -1917,4 +1950,39 @@ pub(crate) mod tests { "Expected log message about insufficient collateral balance" ); } + + /// Test that when gas cost exceeds the order's reward on the pricing ramp, the inline + /// pre-lock gas check keeps the order in cache for retry on the next block. + /// The test order's tiny maxPrice (2 wei) is far below Anvil's gas cost, so the check + /// naturally fails. + #[tokio::test] + #[traced_test] + async fn pre_lock_check_gas_too_high_keeps_order_in_cache() { + let mut ctx = setup_om_test_context().await; + + // Use now_timestamp() for rampUpStart so expires_at() is in the future. + let mut order = + ctx.create_test_order(FulfillmentType::LockAndFulfill, now_timestamp(), 100, 200).await; + // Set gas_estimate so the inline pre-lock check fires. + // lockin_gas_estimate (200k) + fulfill_gas_estimate (300k) = 500k gas. + // At Anvil's default gas price (~1 gwei), cost = 500k * 1e9 = 5e14 wei, + // which far exceeds the order's reward of 2 wei. + order.gas_estimate = Some(500_000); + // Submit request on-chain so lock_order's get_status sees it as open (Unknown). + let _ = ctx.market_service.submit_request(&order.request, &ctx.signer).await.unwrap(); + let order_arc = Arc::new(*order); + let order_id = order_arc.id().clone(); + ctx.monitor.test_insert_into_lock_cache(order_arc.clone()).await; + + let valid = ctx.monitor.get_valid_orders(1, 0).await.unwrap(); + assert_eq!(valid.len(), 1, "expect single order from cache"); + assert_eq!(valid[0].id(), order_id, "valid order must be the one we inserted"); + ctx.monitor.lock_and_prove_orders(&valid).await.unwrap(); + + assert!( + ctx.monitor.test_lock_cache_contains(&order_id).await, + "When gas cost exceeds reward, order stays in cache and will retry next block" + ); + assert!(logs_contain("gas cost"), "Expected log about gas cost exceeding reward"); + } }