From 6899941940ee4ea213d3a1978544232e680b1a11 Mon Sep 17 00:00:00 2001 From: seolaoh Date: Thu, 9 Apr 2026 22:54:45 +0900 Subject: [PATCH] feat(fault-proof): make proposer tx confirmation timeout configurable The signer's hardcoded TIMEOUT_SECONDS=60 is too tight for 3 L1 confirmations on Ethereum mainnet (~36s of pure block time leaves only ~24s for mempool inclusion). Under any congestion the watcher gives up while the tx is still in flight, returns an error, and the proposer immediately retries - racing the original tx and producing duplicate sibling games when the original eventually lands. Add `tx_confirmation_timeout` (env: TX_CONFIRMATION_TIMEOUT) to ProposerConfig with a default of 180s, plumb a new `send_transaction_request_with_timeout` through the signer, and use it for all four proposer L1 tx paths (creation, proving, resolution, bond claim). Challenger and validity proposer continue to use the existing 60s default via the unchanged `send_transaction_request` wrapper. Co-Authored-By: Claude Opus 4.6 (1M context) --- fault-proof/src/config.rs | 11 ++++++++ fault-proof/src/proposer.rs | 24 ++++++++++++++--- fault-proof/tests/common/process.rs | 1 + utils/signer/src/lib.rs | 40 ++++++++++++++++++++++++----- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/fault-proof/src/config.rs b/fault-proof/src/config.rs index 6606b09a8..308c515fd 100644 --- a/fault-proof/src/config.rs +++ b/fault-proof/src/config.rs @@ -78,6 +78,13 @@ pub struct ProposerConfig { /// Optional path to backup file for persisting proposer state across restarts. pub backup_path: Option, + + /// Maximum time (in seconds) to wait for an L1 transaction submitted by the proposer to + /// reach the required number of confirmations before the watcher gives up. Setting this + /// too low risks declaring "confirmation timeout" on transactions that actually land on + /// chain, which can produce duplicate sibling games on retry. Defaults to 180 to leave + /// comfortable headroom for mempool inclusion plus the configured confirmation depth. + pub tx_confirmation_timeout: u64, } /// Helper function to parse a comma-separated list of addresses @@ -139,6 +146,9 @@ impl ProposerConfig { .parse()?, proof_provider: ProofProviderConfig::from_env()?, backup_path: env::var("BACKUP_PATH").ok().map(PathBuf::from), + tx_confirmation_timeout: env::var("TX_CONFIRMATION_TIMEOUT") + .unwrap_or("180".to_string()) + .parse()?, }) } @@ -175,6 +185,7 @@ impl ProposerConfig { min_auction_period = self.proof_provider.min_auction_period, whitelist = ?self.proof_provider.whitelist, backup_path = ?self.backup_path, + tx_confirmation_timeout = self.tx_confirmation_timeout, "Proposer configuration loaded" ); } diff --git a/fault-proof/src/proposer.rs b/fault-proof/src/proposer.rs index 407ac7583..3fc6b3214 100644 --- a/fault-proof/src/proposer.rs +++ b/fault-proof/src/proposer.rs @@ -1118,7 +1118,11 @@ where let transaction_request = game.prove(agg_proof.bytes().into()).into_transaction_request(); let receipt = self .signer - .send_transaction_request(self.config.l1_rpc.clone(), transaction_request) + .send_transaction_request_with_timeout( + self.config.l1_rpc.clone(), + transaction_request, + self.config.tx_confirmation_timeout, + ) .await?; if !receipt.status() { @@ -1181,7 +1185,11 @@ where let receipt = self .signer - .send_transaction_request(self.config.l1_rpc.clone(), transaction_request) + .send_transaction_request_with_timeout( + self.config.l1_rpc.clone(), + transaction_request, + self.config.tx_confirmation_timeout, + ) .await?; if !receipt.status() { @@ -1311,7 +1319,11 @@ where let transaction_request = contract.resolve().into_transaction_request(); let receipt = self .signer - .send_transaction_request(self.config.l1_rpc.clone(), transaction_request) + .send_transaction_request_with_timeout( + self.config.l1_rpc.clone(), + transaction_request, + self.config.tx_confirmation_timeout, + ) .await?; if !receipt.status() { @@ -1337,7 +1349,11 @@ where contract.claimCredit(self.signer.address()).gas(300_000).into_transaction_request(); let receipt = self .signer - .send_transaction_request(self.config.l1_rpc.clone(), transaction_request) + .send_transaction_request_with_timeout( + self.config.l1_rpc.clone(), + transaction_request, + self.config.tx_confirmation_timeout, + ) .await?; if !receipt.status() { diff --git a/fault-proof/tests/common/process.rs b/fault-proof/tests/common/process.rs index 8817df925..d468911bc 100644 --- a/fault-proof/tests/common/process.rs +++ b/fault-proof/tests/common/process.rs @@ -49,6 +49,7 @@ pub async fn new_proposer( range_split_count: RangeSplitCount::one(), max_concurrent_range_proofs: NonZero::::MIN, backup_path, + tx_confirmation_timeout: 180, proof_provider: ProofProviderConfig { timeout: 14400, // 4 hours network_calls_timeout: 15, diff --git a/utils/signer/src/lib.rs b/utils/signer/src/lib.rs index b7508e093..be47de524 100644 --- a/utils/signer/src/lib.rs +++ b/utils/signer/src/lib.rs @@ -93,11 +93,24 @@ impl Signer { } } - /// Sends a transaction request, signed by the configured `signer`. + /// Sends a transaction request, signed by the configured `signer`, using the default + /// confirmation timeout of [`TIMEOUT_SECONDS`]. pub async fn send_transaction_request( + &self, + l1_rpc: Url, + transaction_request: TransactionRequest, + ) -> Result { + self.send_transaction_request_with_timeout(l1_rpc, transaction_request, TIMEOUT_SECONDS) + .await + } + + /// Sends a transaction request, signed by the configured `signer`, with a caller-supplied + /// confirmation timeout (in seconds). + pub async fn send_transaction_request_with_timeout( &self, l1_rpc: Url, mut transaction_request: TransactionRequest, + timeout_secs: u64, ) -> Result { match self { Signer::Web3Signer(signer_url, signer_address) => { @@ -126,7 +139,7 @@ impl Signer { .await .context("Failed to send transaction")? .with_required_confirmations(NUM_CONFIRMATIONS) - .with_timeout(Some(Duration::from_secs(TIMEOUT_SECONDS))) + .with_timeout(Some(Duration::from_secs(timeout_secs))) .get_receipt() .await?; @@ -151,7 +164,7 @@ impl Signer { .await .context("Failed to send transaction")? .with_required_confirmations(NUM_CONFIRMATIONS) - .with_timeout(Some(Duration::from_secs(TIMEOUT_SECONDS))) + .with_timeout(Some(Duration::from_secs(timeout_secs))) .get_receipt() .await?; @@ -177,7 +190,7 @@ impl Signer { .await .context("Failed to send KMS-signed transaction")? .with_required_confirmations(NUM_CONFIRMATIONS) - .with_timeout(Some(Duration::from_secs(TIMEOUT_SECONDS))) + .with_timeout(Some(Duration::from_secs(timeout_secs))) .get_receipt() .await?; @@ -212,8 +225,9 @@ impl SignerLock { self.cached_address } - /// Sends a transaction request, signed by the configured signer. - /// Transactions are serialized via a Mutex to prevent nonce conflicts. + /// Sends a transaction request, signed by the configured signer, using the default + /// confirmation timeout of [`TIMEOUT_SECONDS`]. Transactions are serialized via a Mutex + /// to prevent nonce conflicts. pub async fn send_transaction_request( &self, l1_rpc: Url, @@ -222,6 +236,20 @@ impl SignerLock { let signer = self.inner.lock().await; signer.send_transaction_request(l1_rpc, transaction_request).await } + + /// Sends a transaction request with a caller-supplied confirmation timeout (in seconds). + /// Transactions are serialized via a Mutex to prevent nonce conflicts. + pub async fn send_transaction_request_with_timeout( + &self, + l1_rpc: Url, + transaction_request: TransactionRequest, + timeout_secs: u64, + ) -> Result { + let signer = self.inner.lock().await; + signer + .send_transaction_request_with_timeout(l1_rpc, transaction_request, timeout_secs) + .await + } } #[cfg(test)]