diff --git a/config.toml b/config.toml index bd7fab92..05ca5d6a 100644 --- a/config.toml +++ b/config.toml @@ -146,6 +146,7 @@ gas_buffer = 1.2 # More buffer for inaccurate multicall3 estimations block_time_seconds = 2 receipt_timeout_blocks = 30 # 60 seconds total (30 blocks * 2s) rpc_request_timeout_seconds = 20 +flashblocks = true # Use BlockId::latest() for gas estimation (Base Flashblocks) # Base Sepolia Testnet - Chain ID 84532 [transaction.chains.base-sepolia] diff --git a/src/chain/evm.rs b/src/chain/evm.rs index 1cc4b51c..e7c6c310 100644 --- a/src/chain/evm.rs +++ b/src/chain/evm.rs @@ -230,6 +230,9 @@ pub struct EvmProvider { eip712_version_cache: Arc>>, /// Token manager for dynamic contract selection based on token configuration token_manager: Arc, + /// Use BlockId::latest() instead of BlockId::pending() for gas estimation. + /// For chains with sub-200ms block production (e.g., Base Flashblocks). + flashblocks: bool, } impl EvmProvider { @@ -240,6 +243,7 @@ impl EvmProvider { eip1559: bool, network: Network, token_manager: Arc, + flashblocks: bool, ) -> Result> { let chain = EvmChain::try_from(network)?; let signer_addresses: Vec
= @@ -335,9 +339,14 @@ impl EvmProvider { })?; // Create RPC client with custom HTTP client - let client = RpcClient::builder() + let mut client = RpcClient::builder() .http_with_client(http_client, url); + // Flashblocks chains have sub-200ms blocks; override Alloy's 7s default poll interval + if flashblocks { + client = client.with_poll_interval(std::time::Duration::from_millis(200)); + } + // Create nonce manager explicitly so we can store a reference for error handling let nonce_manager = PendingNonceManager::default(); @@ -368,6 +377,7 @@ impl EvmProvider { nonce_manager, eip712_version_cache: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), token_manager, + flashblocks, }) } @@ -440,6 +450,8 @@ pub trait MetaEvmProvider { fn eip712_cache(&self) -> &Arc>>; /// Returns reference to token manager for dynamic contract selection. fn token_manager(&self) -> &TokenManager; + /// Whether this provider targets a flashblocks chain (sub-200ms blocks). + fn flashblocks(&self) -> bool; /// Sends a meta-transaction to the network. fn send_transaction( @@ -481,6 +493,10 @@ impl MetaEvmProvider for EvmProvider { &self.token_manager } + fn flashblocks(&self) -> bool { + self.flashblocks + } + /// Send a meta-transaction with provided `to`, `calldata`, and automatically selected signer. /// /// This method constructs a transaction from the provided [`MetaTransaction`], automatically @@ -563,28 +579,39 @@ impl MetaEvmProvider for EvmProvider { "Using receipt timeout for transaction" ); - // Apply gas buffer if configured + // Apply gas buffer if configured. + // When flashblocks is enabled, always do explicit gas estimation with BlockId::latest() + // because Alloy's GasFiller uses BlockId::pending() which is unreliable on chains + // with sub-200ms block production (e.g., Base Flashblocks). let gas_buffer = config .as_ref() .map(|c| c.transaction.gas_buffer_for_network(&network_str)) .unwrap_or(1.0); - if gas_buffer > 1.0 { + if gas_buffer > 1.0 || self.flashblocks { + let block_id = if self.flashblocks { + BlockId::latest() + } else { + BlockId::pending() + }; let estimated_gas = self .inner .estimate_gas(txr.clone()) + .block(block_id) .await .map_err(|e| { FacilitatorLocalError::ContractCall(format!("Gas estimation failed: {e:?}")) })?; - let buffered_gas = (estimated_gas as f64 * gas_buffer) as u64; + let effective_buffer = if gas_buffer > 1.0 { gas_buffer } else { 1.0 }; + let buffered_gas = (estimated_gas as f64 * effective_buffer) as u64; tracing::debug!( estimated_gas, - gas_buffer, + gas_buffer = effective_buffer, buffered_gas, + flashblocks = self.flashblocks, network = %network_str, - "Applied gas buffer to transaction" + "Applied gas estimation to transaction" ); txr = txr.with_gas_limit(buffered_gas); } @@ -711,7 +738,13 @@ impl FromEnvByNetworkBuild for EvmProvider { ) }; - let provider = EvmProvider::try_new(wallet, &rpc_url, is_eip1559, network, token_manager).await?; + // Read flashblocks setting from chain config + let flashblocks = crate::config::FacilitatorConfig::from_env() + .ok() + .and_then(|c| c.transaction.chains.get(&network.to_string()).map(|cc| cc.flashblocks)) + .unwrap_or(false); + + let provider = EvmProvider::try_new(wallet, &rpc_url, is_eip1559, network, token_manager, flashblocks).await?; Ok(Some(provider)) } } @@ -741,7 +774,7 @@ where // Perform payment validation WITHOUT balance check (we'll batch it with signature validation) let (contract, payment, eip712_domain) = - assert_valid_payment(self.inner(), self.chain(), payload, requirements, Some(self.eip712_cache()), true, self.token_manager()).await?; + assert_valid_payment(self.inner(), self.chain(), payload, requirements, Some(self.eip712_cache()), true, self.token_manager(), self.flashblocks()).await?; let signed_message = SignedMessage::extract(&payment, &eip712_domain)?; let payer = signed_message.address; @@ -804,6 +837,7 @@ where token_contract = %transfer_call.contract_address, otel.kind = "client", )), + self.flashblocks(), ) .await .map_err(|e| categorize_transport_error(e, "batched verification multicall"))?; @@ -865,6 +899,7 @@ where token_contract = %transfer_call.contract_address, otel.kind = "client", )), + self.flashblocks(), ) .await .map_err(|e| categorize_transport_error(e, "batched verification multicall"))?; @@ -917,6 +952,7 @@ where token_contract = %transfer_call.contract_address, otel.kind = "client", )), + self.flashblocks(), ) .await .map_err(|e| categorize_transport_error(e, "batched verification multicall"))?; @@ -992,6 +1028,7 @@ where token_contract = %transfer_call.contract_address, otel.kind = "client", )), + self.flashblocks(), ) .await .map_err(|e| categorize_transport_error(e, "batched verification multicall"))?; @@ -1044,6 +1081,7 @@ where token_contract = %transfer_call.contract_address, otel.kind = "client", )), + self.flashblocks(), ) .await .map_err(|e| categorize_transport_error(e, "batched verification multicall"))?; @@ -1094,6 +1132,7 @@ where token_contract = %transfer_call.contract_address, otel.kind = "client", )), + self.flashblocks(), ) .await .map_err(|e| categorize_transport_error(e, "batched verification multicall"))?; @@ -1142,7 +1181,7 @@ where let payload = &request.payment_payload; let requirements = &request.payment_requirements; let (contract, payment, eip712_domain) = - assert_valid_payment(self.inner(), self.chain(), payload, requirements, Some(self.eip712_cache()), false, self.token_manager()).await?; + assert_valid_payment(self.inner(), self.chain(), payload, requirements, Some(self.eip712_cache()), false, self.token_manager(), self.flashblocks()).await?; let signed_message = SignedMessage::extract(&payment, &eip712_domain)?; let payer = signed_message.address; @@ -1378,7 +1417,7 @@ impl EvmProvider { // Validate payment and extract contract, payment data, and EIP-712 domain let (contract, payment, eip712_domain) = - assert_valid_payment(self.inner(), self.chain(), payload, requirements, Some(self.eip712_cache()), false, &self.token_manager).await?; + assert_valid_payment(self.inner(), self.chain(), payload, requirements, Some(self.eip712_cache()), false, &self.token_manager, self.flashblocks).await?; let signed_message = SignedMessage::extract(&payment, &eip712_domain)?; let payer = signed_message.address; @@ -1876,17 +1915,26 @@ fn is_unsupported_pending_error(error: &E) -> bool { has_error } -/// Helper function to call a contract method with automatic fallback to "latest" block tag +/// Call a contract method with automatic fallback to "latest" block tag /// if the RPC doesn't support "pending". /// -/// Takes async blocks that produce the call results. +/// When `flashblocks` is true, skips the `try_call` (which uses the default "pending" block +/// tag) and goes directly to `retry_call` (which uses "latest"). On chains with sub-200ms +/// block production (e.g., Base Flashblocks) the "pending" state is unreliable and can cause +/// simulations to revert with stale/inconsistent state. async fn call_with_fallback( try_call: impl std::future::Future>, retry_call: impl std::future::Future>, + flashblocks: bool, ) -> Result where E: std::fmt::Debug, { + if flashblocks { + tracing::trace!("Flashblocks enabled, using latest block directly for contract call"); + return retry_call.await; + } + match try_call.await { Ok(result) => { tracing::trace!("Contract call succeeded on first attempt"); @@ -1956,6 +2004,7 @@ async fn assert_enough_balance( token_contract: &Erc3009Contract

, sender: &EvmAddress, max_amount_required: U256, + flashblocks: bool, ) -> Result<(), FacilitatorLocalError> { let balance = match token_contract { Erc3009Contract::PackedBytes(packed_abi) => match packed_abi { @@ -1982,6 +2031,7 @@ async fn assert_enough_balance( sender = %sender, otel.kind = "client" )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "balance query"))? @@ -2011,6 +2061,7 @@ async fn assert_enough_balance( sender = %sender, otel.kind = "client" )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "balance query"))? @@ -2038,6 +2089,7 @@ async fn assert_enough_balance( sender = %sender, otel.kind = "client" )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "balance query"))? @@ -2130,6 +2182,7 @@ async fn assert_domain( asset_address: &Address, requirements: &PaymentRequirements, version_cache: Option<&Arc>>>, + flashblocks: bool, ) -> Result { let usdc = USDCDeployment::by_network(payload.network); let name = requirements @@ -2189,6 +2242,7 @@ async fn assert_domain( "fetch_eip712_version", otel.kind = "client", )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "fetch EIP-712 version"))? @@ -2214,6 +2268,7 @@ async fn assert_domain( "fetch_eip712_domain", otel.kind = "client", )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "fetch EIP-712 domain"))?; @@ -2238,6 +2293,7 @@ async fn assert_domain( "fetch_eip712_domain", otel.kind = "client", )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "fetch EIP-712 domain"))?; @@ -2273,6 +2329,7 @@ async fn assert_domain( "fetch_eip712_version", otel.kind = "client", )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "fetch EIP-712 version"))? @@ -2298,6 +2355,7 @@ async fn assert_domain( "fetch_eip712_domain", otel.kind = "client", )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "fetch EIP-712 domain"))?; @@ -2322,6 +2380,7 @@ async fn assert_domain( "fetch_eip712_domain", otel.kind = "client", )), + flashblocks, ) .await .map_err(|e| categorize_transport_error(e, "fetch EIP-712 domain"))?; @@ -2409,6 +2468,7 @@ async fn assert_valid_payment( version_cache: Option<&Arc>>>, skip_balance_check: bool, token_manager: &TokenManager, + flashblocks: bool, ) -> Result<(Erc3009Contract

, ExactEvmPayment, Eip712Domain), FacilitatorLocalError> { let payment_payload = match &payload.payload { ExactPaymentPayload::Evm(payload) => payload, @@ -2497,7 +2557,7 @@ async fn assert_valid_payment( create_erc3009_contract(crate::tokens::SignatureFormat::PackedBytes, "abi/USDC.json", asset_address, provider.clone()) }; - let domain = assert_domain(chain, &contract, payload, &asset_address, requirements, version_cache).await?; + let domain = assert_domain(chain, &contract, payload, &asset_address, requirements, version_cache, flashblocks).await?; let amount_required = requirements.max_amount_required.0; if !skip_balance_check { @@ -2505,6 +2565,7 @@ async fn assert_valid_payment( &contract, &payment_payload.authorization.from, amount_required, + flashblocks, ) .await?; } diff --git a/src/config.rs b/src/config.rs index 543c6501..3acfdf42 100644 --- a/src/config.rs +++ b/src/config.rs @@ -197,6 +197,11 @@ pub struct ChainConfig { /// If not set, uses the global transaction.gas_buffer. #[serde(default)] pub gas_buffer: Option, + /// Use `BlockId::latest()` instead of `BlockId::pending()` for gas estimation. + /// Enable for chains with sub-200ms block production (e.g., Base Flashblocks) + /// where the "pending" block state is unreliable. + #[serde(default)] + pub flashblocks: bool, } impl ChainConfig { @@ -661,4 +666,31 @@ enabled = false let config4: FacilitatorConfig = toml::from_str(config4_str).unwrap(); assert!(!config4.batch_settlement.is_enabled_anywhere()); } + + #[test] + fn test_flashblocks_defaults_to_false() { + let config_str = r#" +[transaction.chains.base] +block_time_seconds = 2 +receipt_timeout_blocks = 30 +rpc_request_timeout_seconds = 20 +"#; + let config: FacilitatorConfig = toml::from_str(config_str).unwrap(); + let base = config.transaction.chains.get("base").unwrap(); + assert!(!base.flashblocks); + } + + #[test] + fn test_flashblocks_parses_true() { + let config_str = r#" +[transaction.chains.base] +block_time_seconds = 2 +receipt_timeout_blocks = 30 +rpc_request_timeout_seconds = 20 +flashblocks = true +"#; + let config: FacilitatorConfig = toml::from_str(config_str).unwrap(); + let base = config.transaction.chains.get("base").unwrap(); + assert!(base.flashblocks); + } }