Skip to content

Commit ea99689

Browse files
MauroToscanoMarcosNicolauJuArce
authored
feat(batcher): enforce a min max_fee and min bump for replacements (#2067)
Co-authored-by: Marcos Nicolau <[email protected]> Co-authored-by: Julian Arce <[email protected]>
1 parent 7bf60ac commit ea99689

File tree

9 files changed

+137
-34
lines changed

9 files changed

+137
-34
lines changed

config-files/config-batcher-docker.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,10 @@ batcher:
3131
non_paying:
3232
address: '0xa0Ee7A142d267C1f36714E4a8F75612F20a79720' # Anvil address 9
3333
replacement_private_key: ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # Anvil address 1
34+
# When validating if the msg covers the minimum max fee
35+
# A batch of how many proofs should it cover
36+
amount_of_proofs_for_min_max_fee: 128
37+
# When replacing the message, how much higher should the max fee in comparison to the original one
38+
# The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100
39+
min_bump_percentage: 10
40+

config-files/config-batcher-ethereum-package.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,10 @@ batcher:
2929
non_paying:
3030
address: '0xa0Ee7A142d267C1f36714E4a8F75612F20a79720' # Anvil address 9
3131
replacement_private_key: ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # Anvil address 1
32+
# When validating if the msg covers the minimum max fee
33+
# A batch of how many proofs should it cover
34+
amount_of_proofs_for_min_max_fee: 128
35+
# When replacing the message, how much higher should the max fee in comparison to the original one
36+
# The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100
37+
min_bump_percentage: 10
38+

config-files/config-batcher.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ batcher:
3131
non_paying:
3232
address: '0xa0Ee7A142d267C1f36714E4a8F75612F20a79720' # Anvil address 9
3333
replacement_private_key: ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # Anvil address 1
34+
# When validating if the msg covers the minimum max fee
35+
# A batch of how many proofs should it cover
36+
amount_of_proofs_for_min_max_fee: 128
37+
# When replacing the message, how much higher should the max fee in comparison to the original one
38+
# The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100
39+
min_bump_percentage: 10

crates/batcher/src/config/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ pub struct BatcherConfigFromYaml {
5151
pub metrics_port: u16,
5252
pub telemetry_ip_port_address: String,
5353
pub non_paying: Option<NonPayingConfigFromYaml>,
54+
pub amount_of_proofs_for_min_max_fee: usize,
55+
pub min_bump_percentage: u64,
5456
}
5557

5658
#[derive(Debug, Deserialize)]

crates/batcher/src/lib.rs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ pub struct Batcher {
100100
non_paying_config: Option<NonPayingConfig>,
101101
aggregator_fee_percentage_multiplier: u128,
102102
aggregator_gas_cost: u128,
103+
current_min_max_fee: RwLock<U256>,
104+
amount_of_proofs_for_min_max_fee: usize,
105+
min_bump_percentage: U256,
103106

104107
// Shared state access:
105108
// Two kinds of threads interact with the shared state:
@@ -322,6 +325,8 @@ impl Batcher {
322325
max_proof_size: config.batcher.max_proof_size,
323326
max_batch_byte_size: config.batcher.max_batch_byte_size,
324327
max_batch_proof_qty: config.batcher.max_batch_proof_qty,
328+
amount_of_proofs_for_min_max_fee: config.batcher.amount_of_proofs_for_min_max_fee,
329+
min_bump_percentage: U256::from(config.batcher.min_bump_percentage),
325330
last_uploaded_batch_block: Mutex::new(last_uploaded_batch_block),
326331
pre_verification_is_enabled: config.batcher.pre_verification_is_enabled,
327332
non_paying_config,
@@ -333,6 +338,7 @@ impl Batcher {
333338
batch_state: Mutex::new(batch_state),
334339
user_states,
335340
disabled_verifiers: Mutex::new(disabled_verifiers),
341+
current_min_max_fee: RwLock::new(U256::zero()),
336342
metrics,
337343
telemetry,
338344
}
@@ -853,6 +859,19 @@ impl Batcher {
853859
nonced_verification_data = aux_verification_data
854860
}
855861

862+
// Before moving on to process the message, verify that the max fee covers the
863+
// minimum max fee allowed. This prevents users from spamming with very low max fees
864+
// the min max fee is enforced by checking if it can cover a batch of [`amount_of_proofs_for_min_max_fee`]
865+
let msg_max_fee = nonced_verification_data.max_fee;
866+
if !self.msg_covers_minimum_max_fee(msg_max_fee).await {
867+
send_message(
868+
ws_conn_sink.clone(),
869+
SubmitProofResponseMessage::UnderpricedProof,
870+
)
871+
.await;
872+
return Ok(());
873+
};
874+
856875
// We don't need a batch state lock here, since if the user locks its funds
857876
// after the check, some blocks should pass until he can withdraw.
858877
// It is safe to do just do this here.
@@ -1223,17 +1242,18 @@ impl Batcher {
12231242
return;
12241243
};
12251244

1245+
// Validate that the max fee is at least higher or equal to the original fee + a configurable min_bump_percentage
12261246
let original_max_fee = entry.nonced_verification_data.max_fee;
1227-
// Require 10% fee increase to prevent DoS attacks. While this could theoretically overflow,
1228-
// it would require an attacker to have an impractical amount of Ethereum to reach U256::MAX
1229-
let min_required_fee = original_max_fee + (original_max_fee / U256::from(10)); // 10% increase (1.1x)
1230-
if replacement_max_fee < min_required_fee {
1247+
let min_bump =
1248+
original_max_fee + (original_max_fee * self.min_bump_percentage) / U256::from(100);
1249+
1250+
if replacement_max_fee < min_bump {
12311251
drop(batch_state_guard);
12321252
drop(user_state_guard);
1233-
info!("Replacement message fee increase too small for address {addr}. Original: {original_max_fee:?}, received: {replacement_max_fee:?}, minimum required: {min_required_fee:?}");
1253+
info!("Invalid replacement message for address {addr}, had max fee: {original_max_fee:?}, received fee: {replacement_max_fee:?}, minimum required: {min_bump:?}");
12341254
send_message(
12351255
ws_conn_sink.clone(),
1236-
SubmitProofResponseMessage::InvalidReplacementMessage,
1256+
SubmitProofResponseMessage::UnderpricedProof,
12371257
)
12381258
.await;
12391259
self.metrics.user_error(&["insufficient_fee_increase", ""]);
@@ -1887,8 +1907,23 @@ impl Batcher {
18871907

18881908
let (gas_price, disable_verifiers) =
18891909
tokio::join!(gas_price_future, disabled_verifiers_future);
1910+
18901911
let gas_price = gas_price.map_err(|_| BatcherError::GasPriceError)?;
18911912

1913+
// compute the new min max fee
1914+
let min_max_fee = aligned_sdk::verification_layer::calculate_fee_per_proof_with_gas_price(
1915+
self.amount_of_proofs_for_min_max_fee,
1916+
gas_price,
1917+
);
1918+
// Acquire a write lock to update the latest gas price.
1919+
// The lock is dropped immediately after this assignment completes.
1920+
*self.current_min_max_fee.write().await = min_max_fee;
1921+
info!(
1922+
"Updated min max-fee: {} ETH per proof (batch size: {})",
1923+
ethers::utils::format_ether(min_max_fee),
1924+
self.amount_of_proofs_for_min_max_fee
1925+
);
1926+
18921927
{
18931928
let new_disable_verifiers = disable_verifiers
18941929
.map_err(|e| BatcherError::DisabledVerifiersError(e.to_string()))?;
@@ -2485,6 +2520,11 @@ impl Batcher {
24852520
true
24862521
}
24872522

2523+
async fn msg_covers_minimum_max_fee(&self, msg_max_fee: U256) -> bool {
2524+
let min_max_fee_per_proof = self.current_min_max_fee.read().await;
2525+
msg_max_fee >= *min_max_fee_per_proof
2526+
}
2527+
24882528
/// Checks if the user's balance is unlocked
24892529
/// Returns false if balance is unlocked, logs the error,
24902530
/// and sends it to the metrics server

crates/sdk/src/communication/messaging.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,8 @@ async fn handle_batcher_response(msg: Message) -> Result<BatchInclusionData, Sub
266266
Err(SubmitError::GenericError(e))
267267
}
268268
Ok(SubmitProofResponseMessage::UnderpricedProof) => {
269-
error!("Batcher responded with error: queue limit has been exceeded. Funds have not been spent.");
270-
Err(SubmitError::BatchQueueLimitExceededError)
269+
error!("Batcher responded with error: proof underpriced. Funds have not been spent.");
270+
Err(SubmitError::InvalidMaxFee)
271271
}
272272
Ok(SubmitProofResponseMessage::ServerBusy) => {
273273
error!("Server is busy processing requests, please retry. Funds have not been spent.");

crates/sdk/src/verification_layer/mod.rs

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,12 @@ pub async fn estimate_fee(
135135
) -> Result<U256, errors::FeeEstimateError> {
136136
match fee_estimation_type {
137137
FeeEstimationType::Default => {
138-
calculate_fee_per_proof_for_batch_of_size(eth_rpc_url, DEFAULT_MAX_FEE_BATCH_SIZE).await
138+
estimate_fee_per_proof_with_rpc(DEFAULT_MAX_FEE_BATCH_SIZE, eth_rpc_url).await
139139
}
140140
FeeEstimationType::Instant => {
141-
calculate_fee_per_proof_for_batch_of_size(eth_rpc_url, INSTANT_MAX_FEE_BATCH_SIZE).await
142-
}
143-
FeeEstimationType::Custom(n) => {
144-
calculate_fee_per_proof_for_batch_of_size(eth_rpc_url, n).await
141+
estimate_fee_per_proof_with_rpc(INSTANT_MAX_FEE_BATCH_SIZE, eth_rpc_url).await
145142
}
143+
FeeEstimationType::Custom(n) => estimate_fee_per_proof_with_rpc(n, eth_rpc_url).await,
146144
}
147145
}
148146

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

174-
// Cost for estimate `num_proofs_per_batch` proofs
172+
let fee_per_proof = calculate_fee_per_proof_with_gas_price(num_proofs_in_batch, gas_price);
173+
Ok(fee_per_proof)
174+
}
175+
176+
/// Estimates the fee per proof based on the given batch size and gas price.
177+
///
178+
/// This function models the cost of submitting a batch of proofs to the network
179+
/// by computing an estimated gas cost per proof. The total gas cost is composed of:
180+
/// - a constant base gas cost for any batch submission (`DEFAULT_CONSTANT_GAS_COST`)
181+
/// - an additional gas cost that scales linearly with the number of proofs in the batch
182+
/// (`ADDITIONAL_SUBMISSION_GAS_COST_PER_PROOF * num_proofs_in_batch`)
183+
///
184+
/// The final fee per proof is calculated by:
185+
/// (estimated_gas_per_proof * gas_price * GAS_PRICE_PERCENTAGE_MULTIPLIER) / PERCENTAGE_DIVIDER
186+
///
187+
///
188+
/// # Arguments
189+
/// * `num_proofs_in_batch` - Number of proofs in the batch (must be > 0).
190+
/// * `gas_price` - Current gas price (in wei).
191+
///
192+
/// # Returns
193+
/// * Estimated fee per individual proof (in wei).
194+
///
195+
/// # Panics
196+
/// This function panics if `num_proofs_in_batch` is 0 due to division by zero.
197+
pub fn calculate_fee_per_proof_with_gas_price(num_proofs_in_batch: usize, gas_price: U256) -> U256 {
198+
// Gas cost for `num_proofs_per_batch` proofs
175199
let estimated_gas_per_proof = (DEFAULT_CONSTANT_GAS_COST
176200
+ ADDITIONAL_SUBMISSION_GAS_COST_PER_PROOF * num_proofs_in_batch as u128)
177201
/ num_proofs_in_batch as u128;
178202

179-
// Price of 1 proof in a batch of size `num_proofs_in_batch` i.e. (1 / `num_proofs_in_batch`).
180-
// The computed price is adjusted with respect to the percentage multiplier from:
181-
// https://github.com/yetanotherco/aligned_layer/blob/staging/crates/batcher/src/lib.rs#L1401
182-
let fee_per_proof = (U256::from(estimated_gas_per_proof)
183-
* gas_price
184-
* U256::from(GAS_PRICE_PERCENTAGE_MULTIPLIER))
185-
/ U256::from(PERCENTAGE_DIVIDER);
186-
187-
Ok(fee_per_proof)
203+
(U256::from(estimated_gas_per_proof) * gas_price * U256::from(GAS_PRICE_PERCENTAGE_MULTIPLIER))
204+
/ U256::from(PERCENTAGE_DIVIDER)
188205
}
189206

190207
async fn fetch_gas_price(
@@ -820,10 +837,10 @@ mod test {
820837

821838
#[tokio::test]
822839
async fn computed_max_fee_for_larger_batch_is_smaller() {
823-
let small_fee = calculate_fee_per_proof_for_batch_of_size(HOLESKY_PUBLIC_RPC_URL, 5)
840+
let small_fee = estimate_fee_per_proof_with_rpc(5, HOLESKY_PUBLIC_RPC_URL)
824841
.await
825842
.unwrap();
826-
let large_fee = calculate_fee_per_proof_for_batch_of_size(HOLESKY_PUBLIC_RPC_URL, 2)
843+
let large_fee = estimate_fee_per_proof_with_rpc(2, HOLESKY_PUBLIC_RPC_URL)
827844
.await
828845
.unwrap();
829846

@@ -832,10 +849,10 @@ mod test {
832849

833850
#[tokio::test]
834851
async fn computed_max_fee_for_more_proofs_larger_than_for_less_proofs() {
835-
let small_fee = calculate_fee_per_proof_for_batch_of_size(HOLESKY_PUBLIC_RPC_URL, 20)
852+
let small_fee = estimate_fee_per_proof_with_rpc(20, HOLESKY_PUBLIC_RPC_URL)
836853
.await
837854
.unwrap();
838-
let large_fee = calculate_fee_per_proof_for_batch_of_size(HOLESKY_PUBLIC_RPC_URL, 10)
855+
let large_fee = estimate_fee_per_proof_with_rpc(10, HOLESKY_PUBLIC_RPC_URL)
839856
.await
840857
.unwrap();
841858

docs/3_guides/1.2_SDK_api_reference.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,21 +327,21 @@ pub async fn estimate_fee(
327327
- `EthereumProviderError` if there is an error in the connection with the RPC provider.
328328
- `EthereumCallError` if there is an error in the Ethereum call.
329329

330-
### `calculate_fee_per_proof_for_batch_of_size`
330+
### `estimate_fee_per_proof_with_rpc`
331331

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

334334
```rust
335-
pub async fn calculate_fee_per_proof_for_batch_of_size(
336-
eth_rpc_url: &str,
335+
pub async fn estimate_fee_per_proof_with_rpc(
337336
num_proofs_in_batch: usize,
337+
eth_rpc_url: &str,
338338
) -> Result<U256, errors::FeeEstimateError>
339339
```
340340

341341
#### Arguments
342342

343-
- `eth_rpc_url` - The URL of the users Ethereum RPC node.
344343
- `num_proofs_in_batch` - number of proofs within a batch.
344+
- `eth_rpc_url` - The URL of the users Ethereum RPC node.
345345

346346
#### Returns
347347

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

355+
### `calculate_fee_per_proof_with_gas_price`
356+
357+
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.
358+
359+
```rust
360+
pub fn calculate_fee_per_proof_with_gas_price(
361+
num_proofs_in_batch: usize,
362+
gas_price: U256
363+
) -> U256
364+
```
365+
366+
#### Arguments
367+
368+
- `num_proofs_in_batch` - number of proofs within a batch.
369+
- `gas_price` - Current gas price (in wei).
370+
371+
#### Returns
372+
373+
- `U256` - The estimated fee per individual proof (in wei).
374+
375+
#### Notes
376+
377+
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.
378+
355379
### `deposit_to_aligned`
356380

357381
Funds the batcher payment service in name of the signer.

docs/3_guides/9_aligned_cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Submit a proof to the Aligned Layer batcher.
7272
- `--keystore_path <path_to_local_keystore>`: Path to the local keystore.
7373
- `--private_key <private_key>`: User's wallet private key.
7474
- `--nonce <n>`: Proof nonce.
75-
- By default, the nonce is set automatically. By setting the nonce manually, you can perform a proof replacement.
75+
- 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.
7676
- One of the following, to specify which Network to interact with:
7777
- `--network <working_network_name>`: Network name to interact with.
7878
- Default: `devnet`

0 commit comments

Comments
 (0)