Skip to content

Commit c532036

Browse files
maximopalopoliMarcosNicolauJuArce
authored
feat(aggregation-mode): Bump fee when proof verification times out (#2237)
Co-authored-by: Marcos Nicolau <76252340+MarcosNicolau@users.noreply.github.com> Co-authored-by: JuArce <52429267+JuArce@users.noreply.github.com>
1 parent f21b9ea commit c532036

File tree

4 files changed

+260
-47
lines changed

4 files changed

+260
-47
lines changed

aggregation_mode/proof_aggregator/src/backend/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ pub struct Config {
2121
pub sp1_chunk_aggregator_vk_hash: String,
2222
pub monthly_budget_eth: f64,
2323
pub db_connection_urls: Vec<String>,
24+
pub max_bump_retries: u16,
25+
pub bump_retry_interval_seconds: u64,
26+
pub max_fee_bump_percentage: u64,
27+
pub max_priority_fee_upper_limit: u128,
2428
}
2529

2630
impl Config {

aggregation_mode/proof_aggregator/src/backend/mod.rs

Lines changed: 242 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ use crate::{
2020

2121
use 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
};
3134
use 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

5770
pub 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

6781
impl 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(

config-files/config-proof-aggregator-ethereum-package.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ monthly_budget_eth: 15.0
2727
sp1_chunk_aggregator_vk_hash: "00d6e32a34f68ea643362b96615591c94ee0bf99ee871740ab2337966a4f77af"
2828
risc0_chunk_aggregator_image_id: "8908f01022827e80a5de71908c16ee44f4a467236df20f62e7c994491629d74c"
2929

30+
# These values modify the bumping behavior after the aggregated proof on-chain submission times out.
31+
max_bump_retries: 5
32+
bump_retry_interval_seconds: 120
33+
max_fee_bump_percentage: 100
34+
max_priority_fee_upper_limit: 3000000000 # 3 Gwei
35+
3036
ecdsa:
3137
private_key_store_path: "config-files/anvil.proof-aggregator.ecdsa.key.json"
3238
private_key_store_password: ""

0 commit comments

Comments
 (0)