Skip to content
Open
4 changes: 4 additions & 0 deletions aggregation_mode/proof_aggregator/src/backend/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ pub struct Config {
pub sp1_chunk_aggregator_vk_hash: String,
pub monthly_budget_eth: f64,
pub db_connection_urls: Vec<String>,
pub max_bump_retries: u16,
pub bump_retry_interval_seconds: u64,
pub base_bump_percentage: u64,
pub retry_attempt_percentage: u64,
}

impl Config {
Expand Down
230 changes: 188 additions & 42 deletions aggregation_mode/proof_aggregator/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use alloy::{
consensus::{BlobTransactionSidecar, EnvKzgSettings, EthereumTxEnvelope, TxEip4844WithSidecar},
eips::{eip4844::BYTES_PER_BLOB, eip7594::BlobTransactionSidecarEip7594, Encodable2718},
hex,
network::EthereumWallet,
network::{EthereumWallet, TransactionBuilder},
primitives::{utils::parse_ether, Address, U256},
providers::{PendingTransactionError, Provider, ProviderBuilder},
rpc::types::TransactionReceipt,
Expand Down Expand Up @@ -334,7 +334,67 @@ impl ProofAggregator {

info!("Sending proof to ProofAggregationService contract...");

let tx_req = match aggregated_proof {
let max_retries = self.config.max_bump_retries;

let mut last_error: Option<AggregatedProofSubmissionError> = None;

for attempt in 0..max_retries {
info!("Transaction attempt {} of {}", attempt + 1, max_retries);

// Wrap the entire transaction submission in a result to catch all errors
let attempt_result = self
.try_submit_transaction(
&blob,
blob_versioned_hash,
aggregated_proof,
attempt as u64,
)
.await;

match attempt_result {
Ok(receipt) => {
info!(
"Transaction confirmed successfully on attempt {}",
attempt + 1
);
return Ok(receipt);
}
Err(err) => {
warn!("Attempt {} failed: {:?}", attempt + 1, err);
last_error = Some(err);

if attempt < max_retries - 1 {
info!("Retrying with bumped gas fees...");

tokio::time::sleep(Duration::from_millis(500)).await;
} else {
warn!("Max retries ({}) exceeded", max_retries);
}
}
}
}

// If we exhausted all retries, return the last error
Err(RetryError::Transient(last_error.unwrap_or_else(|| {
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(
"Max retries exceeded with no error details".to_string(),
)
})))
}

async fn try_submit_transaction(
&self,
blob: &BlobTransactionSidecar,
blob_versioned_hash: [u8; 32],
aggregated_proof: &AlignedProof,
attempt: u64,
) -> Result<TransactionReceipt, AggregatedProofSubmissionError> {
let retry_interval = Duration::from_secs(self.config.bump_retry_interval_seconds);
let base_bump_percentage = self.config.base_bump_percentage;
let retry_attempt_percentage = self.config.retry_attempt_percentage;

// Build the transaction request
let mut tx_req = match aggregated_proof {
AlignedProof::SP1(proof) => self
.proof_aggregation_service
.verifyAggregationSP1(
Expand All @@ -343,81 +403,167 @@ impl ProofAggregator {
proof.proof_with_pub_values.bytes().into(),
self.sp1_chunk_aggregator_vk_hash_bytes.into(),
)
.sidecar(blob)
.sidecar(blob.clone())
.into_transaction_request(),
AlignedProof::Risc0(proof) => {
let encoded_seal = encode_seal(&proof.receipt)
.map_err(|e| AggregatedProofSubmissionError::Risc0EncodingSeal(e.to_string()))
.map_err(RetryError::Permanent)?;
let encoded_seal = encode_seal(&proof.receipt).map_err(|e| {
AggregatedProofSubmissionError::Risc0EncodingSeal(e.to_string())
})?;
self.proof_aggregation_service
.verifyAggregationRisc0(
blob_versioned_hash.into(),
encoded_seal.into(),
proof.receipt.journal.bytes.clone().into(),
self.risc0_chunk_aggregator_image_id_bytes.into(),
)
.sidecar(blob)
.sidecar(blob.clone())
.into_transaction_request()
}
};

// Apply gas fee bump for retries
if attempt > 0 {
tx_req = self
.apply_gas_fee_bump(
base_bump_percentage,
retry_attempt_percentage,
attempt,
tx_req,
)
.await?;
}

let provider = self.proof_aggregation_service.provider();

// Fill the transaction
let envelope = provider
.fill(tx_req)
.await
.map_err(|err| {
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(
err.to_string(),
)
})
.map_err(RetryError::Transient)?
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(format!(
"Failed to fill transaction: {err}"
))
})?
.try_into_envelope()
.map_err(|err| {
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(
err.to_string(),
)
})
.map_err(RetryError::Transient)?;
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(format!(
"Failed to convert to envelope: {err}"
))
})?;

// Convert to EIP-4844 transaction
let tx: EthereumTxEnvelope<TxEip4844WithSidecar<BlobTransactionSidecarEip7594>> = envelope
.try_into_pooled()
.map_err(|err| {
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(
err.to_string(),
)
})
.map_err(RetryError::Transient)?
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(format!(
"Failed to pool transaction: {err}"
))
})?
.try_map_eip4844(|tx| {
tx.try_map_sidecar(|sidecar| sidecar.try_into_7594(EnvKzgSettings::Default.get()))
})
.map_err(|err| {
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(
err.to_string(),
)
})
.map_err(RetryError::Transient)?;
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(format!(
"Failed to convert to EIP-7594: {err}"
))
})?;

// Send the transaction
let encoded_tx = tx.encoded_2718();
let pending_tx = provider
.send_raw_transaction(&encoded_tx)
.await
.map_err(|err| {
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(
err.to_string(),
)
})
.map_err(RetryError::Transient)?;
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(format!(
"Failed to send raw transaction: {err}"
))
})?;

info!("Transaction sent, waiting for confirmation...");

// Wait for the receipt with timeout
let receipt_result = tokio::time::timeout(retry_interval, pending_tx.get_receipt()).await;

match receipt_result {
Ok(Ok(receipt)) => Ok(receipt),
Ok(Err(err)) => Err(
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(format!(
"Error getting receipt: {err}"
)),
),
Err(_) => Err(
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(format!(
"Transaction timeout after {} seconds",
retry_interval.as_secs()
)),
),
}
}

let receipt = pending_tx
.get_receipt()
.await
.map_err(|err| {
AggregatedProofSubmissionError::SendVerifyAggregatedProofTransaction(
err.to_string(),
)
})
.map_err(RetryError::Transient)?;
// Updates the gas fees of a `TransactionRequest` for retry attempts by applying a linear fee
// bump based on the retry number. This method is intended to be used when a previous transaction
// attempt was not confirmed (e.g. receipt timeout or transient failure).
//
// Fee strategy (similar to Go implementation):
// The bump is calculated as: base_bump_percentage + (retry_count * retry_attempt_percentage)
// For example, with `base_bump_percentage = 10` and `retry_attempt_percentage = 5`:
// - `attempt = 1` → 10% + (1 * 5%) = 15% bump
// - `attempt = 2` → 10% + (2 * 5%) = 20% bump
// - `attempt = 3` → 10% + (3 * 5%) = 25% bump
//
// The bumped price is: current_gas_price * (1 + total_bump_percentage / 100)
async fn apply_gas_fee_bump(
&self,
base_bump_percentage: u64,
retry_attempt_percentage: u64,
attempt: u64,
tx_req: alloy::rpc::types::TransactionRequest,
) -> Result<alloy::rpc::types::TransactionRequest, AggregatedProofSubmissionError> {
let provider = self.proof_aggregation_service.provider();

// Calculate total bump percentage: base + (retry_count * retry_attempt)
let incremental_retry_percentage = retry_attempt_percentage * attempt;
let total_bump_percentage = base_bump_percentage + incremental_retry_percentage;

info!(
"Applying {}% gas fee bump for attempt {}",
total_bump_percentage,
attempt + 1
);

let mut current_tx_req = tx_req.clone();

if current_tx_req.max_fee_per_gas.is_none() {
let current_gas_price = provider
.get_gas_price()
.await
.map_err(|e| AggregatedProofSubmissionError::GasPriceError(e.to_string()))?;

let new_max_fee =
Self::calculate_bumped_price(current_gas_price, total_bump_percentage);
let new_priority_fee = new_max_fee / 10;

current_tx_req = current_tx_req
.with_max_fee_per_gas(new_max_fee)
.with_max_priority_fee_per_gas(new_priority_fee);
} else {
if let Some(max_fee) = current_tx_req.max_fee_per_gas {
let new_max_fee = Self::calculate_bumped_price(max_fee, total_bump_percentage);
current_tx_req = current_tx_req.with_max_fee_per_gas(new_max_fee);
}
if let Some(priority_fee) = current_tx_req.max_priority_fee_per_gas {
let new_priority_fee =
Self::calculate_bumped_price(priority_fee, total_bump_percentage);
current_tx_req = current_tx_req.with_max_priority_fee_per_gas(new_priority_fee);
}
}

Ok(current_tx_req)
}

Ok(receipt)
fn calculate_bumped_price(current_price: u128, total_bump_percentage: u64) -> u128 {
let bump_amount = (current_price * total_bump_percentage as u128) / 100;
current_price + bump_amount
}

async fn wait_until_can_submit_aggregated_proof(
Expand Down
6 changes: 6 additions & 0 deletions config-files/config-proof-aggregator-ethereum-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ monthly_budget_eth: 15.0
sp1_chunk_aggregator_vk_hash: "00d6e32a34f68ea643362b96615591c94ee0bf99ee871740ab2337966a4f77af"
risc0_chunk_aggregator_image_id: "8908f01022827e80a5de71908c16ee44f4a467236df20f62e7c994491629d74c"

# These values modify the bumping behavior after the aggregated proof on-chain submission times out.
max_bump_retries: 5
bump_retry_interval_seconds: 120
base_bump_percentage: 10
retry_attempt_percentage: 2

ecdsa:
private_key_store_path: "config-files/anvil.proof-aggregator.ecdsa.key.json"
private_key_store_password: ""
10 changes: 8 additions & 2 deletions config-files/config-proof-aggregator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ monthly_budget_eth: 15.0
# These program ids are the ones from the chunk aggregator programs
# Can be found in the Proof Aggregation Service deployment config
# (remember to trim the 0x prefix)
sp1_chunk_aggregator_vk_hash: "00ba19eed0aaeb0151f07b8d3ee7c659bcd29f3021e48fb42766882f55b84509"
risc0_chunk_aggregator_image_id: "d8cfdd5410c70395c0a1af1842a0148428cc46e353355faccfba694dd4862dbf"
sp1_chunk_aggregator_vk_hash: "00d6e32a34f68ea643362b96615591c94ee0bf99ee871740ab2337966a4f77af"
risc0_chunk_aggregator_image_id: "8908f01022827e80a5de71908c16ee44f4a467236df20f62e7c994491629d74c"

# These values modify the bumping behavior after the aggregated proof on-chain submission times out.
max_bump_retries: 5
bump_retry_interval_seconds: 120
base_bump_percentage: 10
retry_attempt_percentage: 2

ecdsa:
private_key_store_path: "config-files/anvil.proof-aggregator.ecdsa.key.json"
Expand Down
Loading