@@ -20,12 +20,15 @@ use crate::{
2020
2121use alloy:: {
2222 consensus:: { BlobTransactionSidecar , EnvKzgSettings , EthereumTxEnvelope , TxEip4844WithSidecar } ,
23- eips:: { eip4844:: BYTES_PER_BLOB , eip7594:: BlobTransactionSidecarEip7594 , Encodable2718 } ,
23+ eips:: {
24+ eip4844:: BYTES_PER_BLOB , eip7594:: BlobTransactionSidecarEip7594 , BlockNumberOrTag ,
25+ Encodable2718 ,
26+ } ,
2427 hex,
25- network:: EthereumWallet ,
26- primitives:: { utils:: parse_ether, Address , U256 } ,
28+ network:: { EthereumWallet , TransactionBuilder } ,
29+ primitives:: { utils:: parse_ether, Address , TxHash , U256 } ,
2730 providers:: { PendingTransactionError , Provider , ProviderBuilder } ,
28- rpc:: types:: TransactionReceipt ,
31+ rpc:: types:: { TransactionReceipt , TransactionRequest } ,
2932 signers:: local:: LocalSigner ,
3033} ;
3134use config:: Config ;
@@ -52,6 +55,16 @@ pub enum AggregatedProofSubmissionError {
5255 MerkleRootMisMatch ,
5356 StoringMerklePaths ( DbError ) ,
5457 GasPriceError ( String ) ,
58+ LatestBlockNotFound ,
59+ BaseFeePerGasMissing ,
60+ }
61+
62+ enum SubmitOutcome {
63+ // NOTE: Boxed because enums are sized to their largest variant; without boxing,
64+ // every `SubmitOutcome` would reserve space for a full `TransactionReceipt`,
65+ // even in the `Pending` case (see clippy::large_enum_variant).
66+ Confirmed ( Box < TransactionReceipt > ) ,
67+ Pending ( TxHash ) ,
5568}
5669
5770pub struct ProofAggregator {
@@ -62,6 +75,7 @@ pub struct ProofAggregator {
6275 sp1_chunk_aggregator_vk_hash_bytes : [ u8 ; 32 ] ,
6376 risc0_chunk_aggregator_image_id_bytes : [ u8 ; 32 ] ,
6477 db : Db ,
78+ signer_address : Address ,
6579}
6680
6781impl ProofAggregator {
@@ -72,7 +86,9 @@ impl ProofAggregator {
7286 config. ecdsa . private_key_store_password . clone ( ) ,
7387 )
7488 . expect ( "Keystore signer should be `cast wallet` compliant" ) ;
75- let wallet = EthereumWallet :: from ( signer) ;
89+ let wallet = EthereumWallet :: from ( signer. clone ( ) ) ;
90+
91+ let signer_address = signer. address ( ) ;
7692
7793 // Check if the monthly budget is non-negative to avoid runtime errors later
7894 let _monthly_budget_in_wei = parse_ether ( & config. monthly_budget_eth . to_string ( ) )
@@ -117,6 +133,7 @@ impl ProofAggregator {
117133 sp1_chunk_aggregator_vk_hash_bytes,
118134 risc0_chunk_aggregator_image_id_bytes,
119135 db,
136+ signer_address,
120137 }
121138 }
122139
@@ -334,7 +351,98 @@ impl ProofAggregator {
334351
335352 info ! ( "Sending proof to ProofAggregationService contract..." ) ;
336353
337- let tx_req = match aggregated_proof {
354+ let max_retries = self . config . max_bump_retries ;
355+
356+ let mut last_error: Option < AggregatedProofSubmissionError > = None ;
357+
358+ let mut pending_hashes: Vec < TxHash > = Vec :: with_capacity ( max_retries as usize ) ;
359+
360+ // Get the nonce once at the beginning and reuse it for all retries
361+ let nonce = self
362+ . proof_aggregation_service
363+ . provider ( )
364+ . get_transaction_count ( self . signer_address )
365+ . await
366+ . map_err ( |e| {
367+ RetryError :: Transient (
368+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction ( format ! (
369+ "Failed to get nonce: {e}"
370+ ) ) ,
371+ )
372+ } ) ?;
373+
374+ info ! ( "Using nonce {}" , nonce) ;
375+
376+ for attempt in 0 ..max_retries {
377+ info ! ( "Transaction attempt {} of {}" , attempt + 1 , max_retries) ;
378+
379+ // Wrap the entire transaction submission in a result to catch all errors, passing
380+ // the same nonce to all attempts
381+ let attempt_result = self
382+ . try_submit_transaction (
383+ & blob,
384+ blob_versioned_hash,
385+ aggregated_proof,
386+ nonce,
387+ attempt,
388+ )
389+ . await ;
390+
391+ match attempt_result {
392+ Ok ( SubmitOutcome :: Confirmed ( receipt) ) => {
393+ info ! (
394+ "Transaction confirmed successfully on attempt {}" ,
395+ attempt + 1
396+ ) ;
397+ return Ok ( * receipt) ;
398+ }
399+ Ok ( SubmitOutcome :: Pending ( tx_hash) ) => {
400+ warn ! (
401+ "Attempt {} timed out waiting for receipt; storing pending tx" ,
402+ attempt + 1
403+ ) ;
404+ pending_hashes. push ( tx_hash) ;
405+ last_error = Some (
406+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction (
407+ "Timed out waiting for receipt" . to_string ( ) ,
408+ ) ,
409+ ) ;
410+ }
411+ Err ( err) => {
412+ warn ! ( "Attempt {} failed: {:?}" , attempt + 1 , err) ;
413+ last_error = Some ( err) ;
414+ }
415+ }
416+
417+ // Check if any pending tx was confirmed before retrying
418+ if let Some ( receipt) = self . check_pending_txs_confirmed ( & pending_hashes) . await {
419+ return Ok ( receipt) ;
420+ }
421+
422+ info ! ( "Retrying with bumped gas fees and same nonce {}..." , nonce) ;
423+ tokio:: time:: sleep ( Duration :: from_millis ( 500 ) ) . await ;
424+ }
425+
426+ warn ! ( "Max retries ({}) exceeded" , max_retries) ;
427+ Err ( RetryError :: Transient ( last_error. unwrap_or_else ( || {
428+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction (
429+ "Max retries exceeded with no error details" . to_string ( ) ,
430+ )
431+ } ) ) )
432+ }
433+
434+ async fn try_submit_transaction (
435+ & self ,
436+ blob : & BlobTransactionSidecar ,
437+ blob_versioned_hash : [ u8 ; 32 ] ,
438+ aggregated_proof : & AlignedProof ,
439+ nonce : u64 ,
440+ attempt : u16 ,
441+ ) -> Result < SubmitOutcome , AggregatedProofSubmissionError > {
442+ let retry_interval = Duration :: from_secs ( self . config . bump_retry_interval_seconds ) ;
443+
444+ // Build the transaction request
445+ let mut tx_req = match aggregated_proof {
338446 AlignedProof :: SP1 ( proof) => self
339447 . proof_aggregation_service
340448 . verifyAggregationSP1 (
@@ -343,81 +451,170 @@ impl ProofAggregator {
343451 proof. proof_with_pub_values . bytes ( ) . into ( ) ,
344452 self . sp1_chunk_aggregator_vk_hash_bytes . into ( ) ,
345453 )
346- . sidecar ( blob)
454+ . sidecar ( blob. clone ( ) )
347455 . into_transaction_request ( ) ,
348456 AlignedProof :: Risc0 ( proof) => {
349- let encoded_seal = encode_seal ( & proof. receipt )
350- . map_err ( |e| AggregatedProofSubmissionError :: Risc0EncodingSeal ( e. to_string ( ) ) )
351- . map_err ( RetryError :: Permanent ) ?;
457+ let encoded_seal = encode_seal ( & proof. receipt ) . map_err ( |e| {
458+ AggregatedProofSubmissionError :: Risc0EncodingSeal ( e. to_string ( ) )
459+ } ) ?;
352460 self . proof_aggregation_service
353461 . verifyAggregationRisc0 (
354462 blob_versioned_hash. into ( ) ,
355463 encoded_seal. into ( ) ,
356464 proof. receipt . journal . bytes . clone ( ) . into ( ) ,
357465 self . risc0_chunk_aggregator_image_id_bytes . into ( ) ,
358466 )
359- . sidecar ( blob)
467+ . sidecar ( blob. clone ( ) )
360468 . into_transaction_request ( )
361469 }
362470 } ;
363471
472+ // Set the nonce explicitly
473+ tx_req = tx_req. with_nonce ( nonce) ;
474+
475+ // Apply gas fee bump for retries
476+ tx_req = self . apply_gas_fee_bump ( tx_req, attempt) . await ?;
477+
364478 let provider = self . proof_aggregation_service . provider ( ) ;
479+
480+ // Fill the transaction
365481 let envelope = provider
366482 . fill ( tx_req)
367483 . await
368484 . map_err ( |err| {
369- AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction (
370- err. to_string ( ) ,
371- )
372- } )
373- . map_err ( RetryError :: Transient ) ?
485+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction ( format ! (
486+ "Failed to fill transaction: {err}"
487+ ) )
488+ } ) ?
374489 . try_into_envelope ( )
375490 . map_err ( |err| {
376- AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction (
377- err. to_string ( ) ,
378- )
379- } )
380- . map_err ( RetryError :: Transient ) ?;
491+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction ( format ! (
492+ "Failed to convert to envelope: {err}"
493+ ) )
494+ } ) ?;
495+
496+ // Convert to EIP-4844 transaction
381497 let tx: EthereumTxEnvelope < TxEip4844WithSidecar < BlobTransactionSidecarEip7594 > > = envelope
382498 . try_into_pooled ( )
383499 . map_err ( |err| {
384- AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction (
385- err. to_string ( ) ,
386- )
387- } )
388- . map_err ( RetryError :: Transient ) ?
500+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction ( format ! (
501+ "Failed to pool transaction: {err}"
502+ ) )
503+ } ) ?
389504 . try_map_eip4844 ( |tx| {
390505 tx. try_map_sidecar ( |sidecar| sidecar. try_into_7594 ( EnvKzgSettings :: Default . get ( ) ) )
391506 } )
392507 . map_err ( |err| {
393- AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction (
394- err. to_string ( ) ,
395- )
396- } )
397- . map_err ( RetryError :: Transient ) ?;
508+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction ( format ! (
509+ "Failed to convert to EIP-7594: {err}"
510+ ) )
511+ } ) ?;
398512
513+ // Send the transaction
399514 let encoded_tx = tx. encoded_2718 ( ) ;
400515 let pending_tx = provider
401516 . send_raw_transaction ( & encoded_tx)
402517 . await
403518 . map_err ( |err| {
404- AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction (
405- err. to_string ( ) ,
406- )
407- } )
408- . map_err ( RetryError :: Transient ) ?;
519+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction ( format ! (
520+ "Failed to send raw transaction: {err}"
521+ ) )
522+ } ) ?;
523+
524+ let tx_hash = * pending_tx. tx_hash ( ) ;
525+
526+ let receipt_result = tokio:: time:: timeout ( retry_interval, pending_tx. get_receipt ( ) ) . await ;
527+
528+ match receipt_result {
529+ Ok ( Ok ( receipt) ) => Ok ( SubmitOutcome :: Confirmed ( Box :: new ( receipt) ) ) ,
530+ Ok ( Err ( err) ) => Err (
531+ AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction ( format ! (
532+ "Error getting receipt: {err}"
533+ ) ) ,
534+ ) ,
535+ Err ( _) => Ok ( SubmitOutcome :: Pending ( tx_hash) ) ,
536+ }
537+ }
538+
539+ // Checks if any of the pending transactions have been confirmed.
540+ // Returns the receipt if one is found, otherwise None.
541+ async fn check_pending_txs_confirmed (
542+ & self ,
543+ pending_hashes : & [ TxHash ] ,
544+ ) -> Option < TransactionReceipt > {
545+ for tx_hash in pending_hashes {
546+ if let Ok ( Some ( receipt) ) = self
547+ . proof_aggregation_service
548+ . provider ( )
549+ . get_transaction_receipt ( * tx_hash)
550+ . await
551+ {
552+ info ! ( "Pending tx {} confirmed before retry" , tx_hash) ;
553+ return Some ( receipt) ;
554+ }
555+ }
556+ None
557+ }
558+
559+ // Updates the gas fees of a `TransactionRequest` using EIP-1559 fee parameters.
560+ // Intended for retrying an on-chain submission after a timeout.
561+ //
562+ // Strategy:
563+ // - Fetch the current base fee from the latest block.
564+ // - Fetch the suggested priority fee from the network (eth_maxPriorityFeePerGas).
565+ // - Compute priority fee as: suggested * (1 + (attempt + 1) * 0.1), capped at `max_priority_fee_upper_limit`.
566+ // - Compute `max_fee_per_gas` as: (1 + max_fee_bump_percentage/100) * base_fee + priority_fee.
567+ //
568+ // Fees are recomputed on each retry using the latest base fee.
569+
570+ async fn apply_gas_fee_bump (
571+ & self ,
572+ tx_req : TransactionRequest ,
573+ attempt : u16 ,
574+ ) -> Result < TransactionRequest , AggregatedProofSubmissionError > {
575+ let provider = self . proof_aggregation_service . provider ( ) ;
409576
410- let receipt = pending_tx
411- . get_receipt ( )
577+ let max_fee_bump_percentage = self . config . max_fee_bump_percentage ;
578+ let max_priority_fee_upper_limit = self . config . max_priority_fee_upper_limit ;
579+
580+ let latest_block = provider
581+ . get_block_by_number ( BlockNumberOrTag :: Latest )
412582 . await
413- . map_err ( |err| {
414- AggregatedProofSubmissionError :: SendVerifyAggregatedProofTransaction (
415- err. to_string ( ) ,
416- )
417- } )
418- . map_err ( RetryError :: Transient ) ?;
583+ . map_err ( |e| AggregatedProofSubmissionError :: GasPriceError ( e. to_string ( ) ) ) ?
584+ . ok_or ( AggregatedProofSubmissionError :: LatestBlockNotFound ) ?;
585+
586+ let current_base_fee = latest_block
587+ . header
588+ . base_fee_per_gas
589+ . ok_or ( AggregatedProofSubmissionError :: BaseFeePerGasMissing ) ?
590+ as f64 ;
591+
592+ // Fetch suggested priority fee from the network
593+ let suggested_priority_fee = provider
594+ . get_max_priority_fee_per_gas ( )
595+ . await
596+ . map_err ( |e| AggregatedProofSubmissionError :: GasPriceError ( e. to_string ( ) ) ) ?;
597+
598+ // Calculate priority fee: suggested * (attempt + 1), capped at max
599+ let priority_fee_multiplier = ( attempt + 1 ) as u128 ;
600+ let max_priority_fee_per_gas =
601+ ( suggested_priority_fee * priority_fee_multiplier) . min ( max_priority_fee_upper_limit) ;
602+
603+ // Calculate max fee with cumulative bump per attempt to ensure replacement tx is accepted
604+ let max_fee_multiplier = 1.0 + max_fee_bump_percentage as f64 / 100.0 ;
605+ let max_fee_per_gas =
606+ ( max_fee_multiplier * current_base_fee) as u128 + max_priority_fee_per_gas;
607+
608+ info ! (
609+ "Base fee: {:.4} Gwei. Applying max_fee_per_gas: {:.4} Gwei and max_priority_fee_per_gas: {:.4} Gwei to tx" ,
610+ current_base_fee / 1e9 ,
611+ max_fee_per_gas as f64 / 1e9 ,
612+ max_priority_fee_per_gas as f64 / 1e9
613+ ) ;
419614
420- Ok ( receipt)
615+ Ok ( tx_req
616+ . with_max_fee_per_gas ( max_fee_per_gas)
617+ . with_max_priority_fee_per_gas ( max_priority_fee_per_gas) )
421618 }
422619
423620 async fn wait_until_can_submit_aggregated_proof (
0 commit comments