Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ca16af0
feat: enforce min max fee
MarcosNicolau Jul 3, 2025
4354573
feat: enforce min bump in replacement messages
MarcosNicolau Jul 3, 2025
6568098
fix: underproced proof error message
MarcosNicolau Jul 3, 2025
a029194
fix: min bump calculation
MarcosNicolau Jul 3, 2025
d4981dd
feat: move constants to config file
MarcosNicolau Jul 4, 2025
6f32282
chore: address clippy warnings
MarcosNicolau Jul 4, 2025
546aba9
perf: drop latest_block_gas_price lock early
MarcosNicolau Jul 10, 2025
f534b84
chore: address juli's comments
MarcosNicolau Jul 10, 2025
e2e396e
fix: config file new fields syntax
MarcosNicolau Jul 10, 2025
b2f26b0
chore: rename proofs_to_cover_min_max_fee for amount_of_proofs_for_mi…
MarcosNicolau Jul 10, 2025
02e456b
chore: log min max fee in waiting for batch to finish
MarcosNicolau Jul 10, 2025
45b8a23
feat: compute min max fee only once
MarcosNicolau Jul 10, 2025
63ea290
chore: entry for entry_to_replace name in replacament message
MarcosNicolau Jul 10, 2025
9fdcd5e
fix: logs
JuArce Jul 10, 2025
f3cb180
docs: add replacement rules
JuArce Jul 11, 2025
999d9b0
Merge branch 'staging' into feat/min-max-fee-min-bump
MauroToscano Aug 20, 2025
a2045cf
Merge
MauroToscano Aug 25, 2025
201c366
Merge
MauroToscano Aug 25, 2025
718cd26
Merge branch 'staging' into feat/min-fee-merge
MauroToscano Aug 27, 2025
cb10892
Remove duplicated check
MauroToscano Aug 27, 2025
dacfb75
Remove extra newlines
MauroToscano Aug 27, 2025
0e33365
Rename and standarize arguments of estimate functions
MauroToscano Aug 27, 2025
8a85956
fmt
MarcosNicolau Aug 29, 2025
6212338
fix: set amount_of_proofs_for_min_max_fee to 128
JuArce Aug 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config-files/config-batcher-docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,10 @@ batcher:
non_paying:
address: '0xa0Ee7A142d267C1f36714E4a8F75612F20a79720' # Anvil address 9
replacement_private_key: ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # Anvil address 1
# When validating if the msg covers the minimum max fee
# A batch of how many proofs should it cover
amount_of_proofs_for_min_max_fee: 128
# When replacing the message, how much higher should the max fee in comparison to the original one
# The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100
min_bump_percentage: 10

7 changes: 7 additions & 0 deletions config-files/config-batcher-ethereum-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,10 @@ batcher:
non_paying:
address: '0xa0Ee7A142d267C1f36714E4a8F75612F20a79720' # Anvil address 9
replacement_private_key: ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # Anvil address 1
# When validating if the msg covers the minimum max fee
# A batch of how many proofs should it cover
amount_of_proofs_for_min_max_fee: 128
# When replacing the message, how much higher should the max fee in comparison to the original one
# The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100
min_bump_percentage: 10

6 changes: 6 additions & 0 deletions config-files/config-batcher.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ batcher:
non_paying:
address: '0xa0Ee7A142d267C1f36714E4a8F75612F20a79720' # Anvil address 9
replacement_private_key: ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # Anvil address 1
# When validating if the msg covers the minimum max fee
# A batch of how many proofs should it cover
amount_of_proofs_for_min_max_fee: 128
# When replacing the message, how much higher should the max fee in comparison to the original one
# The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100
min_bump_percentage: 10
15 changes: 0 additions & 15 deletions crates/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/batcher/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pub struct BatcherConfigFromYaml {
pub metrics_port: u16,
pub telemetry_ip_port_address: String,
pub non_paying: Option<NonPayingConfigFromYaml>,
pub amount_of_proofs_for_min_max_fee: usize,
pub min_bump_percentage: u64,
}

#[derive(Debug, Deserialize)]
Expand Down
52 changes: 46 additions & 6 deletions crates/batcher/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ pub struct Batcher {
non_paying_config: Option<NonPayingConfig>,
aggregator_fee_percentage_multiplier: u128,
aggregator_gas_cost: u128,
current_min_max_fee: RwLock<U256>,
amount_of_proofs_for_min_max_fee: usize,
min_bump_percentage: U256,

// Shared state access:
// Two kinds of threads interact with the shared state:
Expand Down Expand Up @@ -322,6 +325,8 @@ impl Batcher {
max_proof_size: config.batcher.max_proof_size,
max_batch_byte_size: config.batcher.max_batch_byte_size,
max_batch_proof_qty: config.batcher.max_batch_proof_qty,
amount_of_proofs_for_min_max_fee: config.batcher.amount_of_proofs_for_min_max_fee,
min_bump_percentage: U256::from(config.batcher.min_bump_percentage),
last_uploaded_batch_block: Mutex::new(last_uploaded_batch_block),
pre_verification_is_enabled: config.batcher.pre_verification_is_enabled,
non_paying_config,
Expand All @@ -333,6 +338,7 @@ impl Batcher {
batch_state: Mutex::new(batch_state),
user_states,
disabled_verifiers: Mutex::new(disabled_verifiers),
current_min_max_fee: RwLock::new(U256::zero()),
metrics,
telemetry,
}
Expand Down Expand Up @@ -853,6 +859,19 @@ impl Batcher {
nonced_verification_data = aux_verification_data
}

// Before moving on to process the message, verify that the max fee covers the
// minimum max fee allowed. This prevents users from spamming with very low max fees
// the min max fee is enforced by checking if it can cover a batch of [`amount_of_proofs_for_min_max_fee`]
let msg_max_fee = nonced_verification_data.max_fee;
if !self.msg_covers_minimum_max_fee(msg_max_fee).await {
send_message(
ws_conn_sink.clone(),
SubmitProofResponseMessage::UnderpricedProof,
)
.await;
return Ok(());
};

// We don't need a batch state lock here, since if the user locks its funds
// after the check, some blocks should pass until he can withdraw.
// It is safe to do just do this here.
Expand Down Expand Up @@ -1223,17 +1242,18 @@ impl Batcher {
return;
};

// Validate that the max fee is at least higher or equal to the original fee + a configurable min_bump_percentage
let original_max_fee = entry.nonced_verification_data.max_fee;
// Require 10% fee increase to prevent DoS attacks. While this could theoretically overflow,
// it would require an attacker to have an impractical amount of Ethereum to reach U256::MAX
let min_required_fee = original_max_fee + (original_max_fee / U256::from(10)); // 10% increase (1.1x)
if replacement_max_fee < min_required_fee {
let min_bump =
original_max_fee + (original_max_fee * self.min_bump_percentage) / U256::from(100);

if replacement_max_fee < min_bump {
drop(batch_state_guard);
drop(user_state_guard);
info!("Replacement message fee increase too small for address {addr}. Original: {original_max_fee:?}, received: {replacement_max_fee:?}, minimum required: {min_required_fee:?}");
info!("Invalid replacement message for address {addr}, had max fee: {original_max_fee:?}, received fee: {replacement_max_fee:?}, minimum required: {min_bump:?}");
send_message(
ws_conn_sink.clone(),
SubmitProofResponseMessage::InvalidReplacementMessage,
SubmitProofResponseMessage::UnderpricedProof,
)
.await;
self.metrics.user_error(&["insufficient_fee_increase", ""]);
Expand Down Expand Up @@ -1887,8 +1907,23 @@ impl Batcher {

let (gas_price, disable_verifiers) =
tokio::join!(gas_price_future, disabled_verifiers_future);

let gas_price = gas_price.map_err(|_| BatcherError::GasPriceError)?;

// compute the new min max fee
let min_max_fee = aligned_sdk::verification_layer::calculate_fee_per_proof_with_gas_price(
self.amount_of_proofs_for_min_max_fee,
gas_price,
);
// Acquire a write lock to update the latest gas price.
// The lock is dropped immediately after this assignment completes.
*self.current_min_max_fee.write().await = min_max_fee;
info!(
"Updated min max-fee: {} ETH per proof (batch size: {})",
ethers::utils::format_ether(min_max_fee),
self.amount_of_proofs_for_min_max_fee
);

{
let new_disable_verifiers = disable_verifiers
.map_err(|e| BatcherError::DisabledVerifiersError(e.to_string()))?;
Expand Down Expand Up @@ -2485,6 +2520,11 @@ impl Batcher {
true
}

async fn msg_covers_minimum_max_fee(&self, msg_max_fee: U256) -> bool {
let min_max_fee_per_proof = self.current_min_max_fee.read().await;
msg_max_fee >= *min_max_fee_per_proof
}

/// Checks if the user's balance is unlocked
/// Returns false if balance is unlocked, logs the error,
/// and sends it to the metrics server
Expand Down
4 changes: 2 additions & 2 deletions crates/sdk/src/communication/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ async fn handle_batcher_response(msg: Message) -> Result<BatchInclusionData, Sub
Err(SubmitError::GenericError(e))
}
Ok(SubmitProofResponseMessage::UnderpricedProof) => {
error!("Batcher responded with error: queue limit has been exceeded. Funds have not been spent.");
Err(SubmitError::BatchQueueLimitExceededError)
error!("Batcher responded with error: proof underpriced. Funds have not been spent.");
Err(SubmitError::InvalidMaxFee)
}
Ok(SubmitProofResponseMessage::ServerBusy) => {
error!("Server is busy processing requests, please retry. Funds have not been spent.");
Expand Down
59 changes: 38 additions & 21 deletions crates/sdk/src/verification_layer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,12 @@ pub async fn estimate_fee(
) -> Result<U256, errors::FeeEstimateError> {
match fee_estimation_type {
FeeEstimationType::Default => {
calculate_fee_per_proof_for_batch_of_size(eth_rpc_url, DEFAULT_MAX_FEE_BATCH_SIZE).await
estimate_fee_per_proof_with_rpc(DEFAULT_MAX_FEE_BATCH_SIZE, eth_rpc_url).await
}
FeeEstimationType::Instant => {
calculate_fee_per_proof_for_batch_of_size(eth_rpc_url, INSTANT_MAX_FEE_BATCH_SIZE).await
}
FeeEstimationType::Custom(n) => {
calculate_fee_per_proof_for_batch_of_size(eth_rpc_url, n).await
estimate_fee_per_proof_with_rpc(INSTANT_MAX_FEE_BATCH_SIZE, eth_rpc_url).await
}
FeeEstimationType::Custom(n) => estimate_fee_per_proof_with_rpc(n, eth_rpc_url).await,
}
}

Expand All @@ -161,30 +159,49 @@ pub async fn estimate_fee(
/// # Errors
/// * `EthereumProviderError` if there is an error in the connection with the RPC provider.
/// * `EthereumGasPriceError` if there is an error retrieving the Ethereum gas price.
pub async fn calculate_fee_per_proof_for_batch_of_size(
eth_rpc_url: &str,
pub async fn estimate_fee_per_proof_with_rpc(
num_proofs_in_batch: usize,
eth_rpc_url: &str,
) -> Result<U256, errors::FeeEstimateError> {
let eth_rpc_provider =
Provider::<Http>::try_from(eth_rpc_url).map_err(|e: url::ParseError| {
errors::FeeEstimateError::EthereumProviderError(e.to_string())
})?;
let gas_price = fetch_gas_price(&eth_rpc_provider).await?;

// Cost for estimate `num_proofs_per_batch` proofs
let fee_per_proof = calculate_fee_per_proof_with_gas_price(num_proofs_in_batch, gas_price);
Ok(fee_per_proof)
}

/// Estimates the fee per proof based on the given batch size and gas price.
///
/// This function models the cost of submitting a batch of proofs to the network
/// by computing an estimated gas cost per proof. The total gas cost is composed of:
/// - a constant base gas cost for any batch submission (`DEFAULT_CONSTANT_GAS_COST`)
/// - an additional gas cost that scales linearly with the number of proofs in the batch
/// (`ADDITIONAL_SUBMISSION_GAS_COST_PER_PROOF * num_proofs_in_batch`)
///
/// The final fee per proof is calculated by:
/// (estimated_gas_per_proof * gas_price * GAS_PRICE_PERCENTAGE_MULTIPLIER) / PERCENTAGE_DIVIDER
///
///
/// # Arguments
/// * `num_proofs_in_batch` - Number of proofs in the batch (must be > 0).
/// * `gas_price` - Current gas price (in wei).
///
/// # Returns
/// * Estimated fee per individual proof (in wei).
///
/// # Panics
/// This function panics if `num_proofs_in_batch` is 0 due to division by zero.
pub fn calculate_fee_per_proof_with_gas_price(num_proofs_in_batch: usize, gas_price: U256) -> U256 {
// Gas cost for `num_proofs_per_batch` proofs
let estimated_gas_per_proof = (DEFAULT_CONSTANT_GAS_COST
+ ADDITIONAL_SUBMISSION_GAS_COST_PER_PROOF * num_proofs_in_batch as u128)
/ num_proofs_in_batch as u128;

// Price of 1 proof in a batch of size `num_proofs_in_batch` i.e. (1 / `num_proofs_in_batch`).
// The computed price is adjusted with respect to the percentage multiplier from:
// https://github.com/yetanotherco/aligned_layer/blob/staging/crates/batcher/src/lib.rs#L1401
let fee_per_proof = (U256::from(estimated_gas_per_proof)
* gas_price
* U256::from(GAS_PRICE_PERCENTAGE_MULTIPLIER))
/ U256::from(PERCENTAGE_DIVIDER);

Ok(fee_per_proof)
(U256::from(estimated_gas_per_proof) * gas_price * U256::from(GAS_PRICE_PERCENTAGE_MULTIPLIER))
/ U256::from(PERCENTAGE_DIVIDER)
}

async fn fetch_gas_price(
Expand Down Expand Up @@ -820,10 +837,10 @@ mod test {

#[tokio::test]
async fn computed_max_fee_for_larger_batch_is_smaller() {
let small_fee = calculate_fee_per_proof_for_batch_of_size(HOLESKY_PUBLIC_RPC_URL, 5)
let small_fee = estimate_fee_per_proof_with_rpc(5, HOLESKY_PUBLIC_RPC_URL)
.await
.unwrap();
let large_fee = calculate_fee_per_proof_for_batch_of_size(HOLESKY_PUBLIC_RPC_URL, 2)
let large_fee = estimate_fee_per_proof_with_rpc(2, HOLESKY_PUBLIC_RPC_URL)
.await
.unwrap();

Expand All @@ -832,10 +849,10 @@ mod test {

#[tokio::test]
async fn computed_max_fee_for_more_proofs_larger_than_for_less_proofs() {
let small_fee = calculate_fee_per_proof_for_batch_of_size(HOLESKY_PUBLIC_RPC_URL, 20)
let small_fee = estimate_fee_per_proof_with_rpc(20, HOLESKY_PUBLIC_RPC_URL)
.await
.unwrap();
let large_fee = calculate_fee_per_proof_for_batch_of_size(HOLESKY_PUBLIC_RPC_URL, 10)
let large_fee = estimate_fee_per_proof_with_rpc(10, HOLESKY_PUBLIC_RPC_URL)
.await
.unwrap();

Expand Down
32 changes: 28 additions & 4 deletions docs/3_guides/1.2_SDK_api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,21 +327,21 @@ pub async fn estimate_fee(
- `EthereumProviderError` if there is an error in the connection with the RPC provider.
- `EthereumCallError` if there is an error in the Ethereum call.

### `calculate_fee_per_proof_for_batch_of_size`
### `estimate_fee_per_proof_with_rpc`

Returns the `fee_per_proof` based on the current gas price for a batch compromised of `num_proofs_per_batch`

```rust
pub async fn calculate_fee_per_proof_for_batch_of_size(
eth_rpc_url: &str,
pub async fn estimate_fee_per_proof_with_rpc(
num_proofs_in_batch: usize,
eth_rpc_url: &str,
) -> Result<U256, errors::FeeEstimateError>
```

#### Arguments

- `eth_rpc_url` - The URL of the users Ethereum RPC node.
- `num_proofs_in_batch` - number of proofs within a batch.
- `eth_rpc_url` - The URL of the users Ethereum RPC node.

#### Returns

Expand All @@ -352,6 +352,30 @@ pub async fn calculate_fee_per_proof_for_batch_of_size(
-`EthereumProviderError` if there is an error in the connection with the RPC provider.
-`EthereumGasPriceError` if there is an error retrieving the Ethereum gas price.

### `calculate_fee_per_proof_with_gas_price`

Calculates the fee per proof based on a given batch size and gas price. This is a pure calculation function that doesn't make any network calls.

```rust
pub fn calculate_fee_per_proof_with_gas_price(
num_proofs_in_batch: usize,
gas_price: U256
) -> U256
```

#### Arguments

- `num_proofs_in_batch` - number of proofs within a batch.
- `gas_price` - Current gas price (in wei).

#### Returns

- `U256` - The estimated fee per individual proof (in wei).

#### Notes

This function is used internally by both `estimate_fee` and `estimate_fee_per_proof_with_rpc`. It performs the core fee calculation logic without any network dependencies.

### `deposit_to_aligned`

Funds the batcher payment service in name of the signer.
Expand Down
2 changes: 1 addition & 1 deletion docs/3_guides/9_aligned_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Submit a proof to the Aligned Layer batcher.
- `--keystore_path <path_to_local_keystore>`: Path to the local keystore.
- `--private_key <private_key>`: User's wallet private key.
- `--nonce <n>`: Proof nonce.
- By default, the nonce is set automatically. By setting the nonce manually, you can perform a proof replacement.
- By default, the nonce is set automatically. By setting the nonce manually, you can perform a proof replacement. To perform a valid replacement, the new proof must have a max_fee 10% higher than the previous one.
- One of the following, to specify which Network to interact with:
- `--network <working_network_name>`: Network name to interact with.
- Default: `devnet`
Expand Down
Loading