diff --git a/config-files/config-batcher-docker.yaml b/config-files/config-batcher-docker.yaml index b50f1ecaae..a6a71a02f5 100644 --- a/config-files/config-batcher-docker.yaml +++ b/config-files/config-batcher-docker.yaml @@ -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 + diff --git a/config-files/config-batcher-ethereum-package.yaml b/config-files/config-batcher-ethereum-package.yaml index 6647ad3132..809d195781 100644 --- a/config-files/config-batcher-ethereum-package.yaml +++ b/config-files/config-batcher-ethereum-package.yaml @@ -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 + diff --git a/config-files/config-batcher.yaml b/config-files/config-batcher.yaml index d31cff0602..228c8ffe4c 100644 --- a/config-files/config-batcher.yaml +++ b/config-files/config-batcher.yaml @@ -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 diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 4432922450..1d03e6005a 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -103,7 +103,6 @@ dependencies = [ "bytes", "ciborium", "clap", - "dashmap", "dotenvy", "env_logger", "ethers", @@ -2041,20 +2040,6 @@ dependencies = [ "syn 2.0.100", ] -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "dashu" version = "0.4.2" diff --git a/crates/batcher/src/config/mod.rs b/crates/batcher/src/config/mod.rs index f52dc05eeb..5138b7c13b 100644 --- a/crates/batcher/src/config/mod.rs +++ b/crates/batcher/src/config/mod.rs @@ -51,6 +51,8 @@ pub struct BatcherConfigFromYaml { pub metrics_port: u16, pub telemetry_ip_port_address: String, pub non_paying: Option, + pub amount_of_proofs_for_min_max_fee: usize, + pub min_bump_percentage: u64, } #[derive(Debug, Deserialize)] diff --git a/crates/batcher/src/lib.rs b/crates/batcher/src/lib.rs index 7a1bf78718..d487c93843 100644 --- a/crates/batcher/src/lib.rs +++ b/crates/batcher/src/lib.rs @@ -100,6 +100,9 @@ pub struct Batcher { non_paying_config: Option, aggregator_fee_percentage_multiplier: u128, aggregator_gas_cost: u128, + current_min_max_fee: RwLock, + amount_of_proofs_for_min_max_fee: usize, + min_bump_percentage: U256, // Shared state access: // Two kinds of threads interact with the shared state: @@ -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, @@ -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, } @@ -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. @@ -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", ""]); @@ -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()))?; @@ -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 diff --git a/crates/sdk/src/communication/messaging.rs b/crates/sdk/src/communication/messaging.rs index 19d593c938..fb9f6f90ae 100644 --- a/crates/sdk/src/communication/messaging.rs +++ b/crates/sdk/src/communication/messaging.rs @@ -266,8 +266,8 @@ async fn handle_batcher_response(msg: Message) -> Result { - 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."); diff --git a/crates/sdk/src/verification_layer/mod.rs b/crates/sdk/src/verification_layer/mod.rs index 9ddadba5b8..155508658f 100644 --- a/crates/sdk/src/verification_layer/mod.rs +++ b/crates/sdk/src/verification_layer/mod.rs @@ -135,14 +135,12 @@ pub async fn estimate_fee( ) -> Result { 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, } } @@ -161,9 +159,9 @@ 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 { let eth_rpc_provider = Provider::::try_from(eth_rpc_url).map_err(|e: url::ParseError| { @@ -171,20 +169,39 @@ pub async fn calculate_fee_per_proof_for_batch_of_size( })?; let gas_price = fetch_gas_price(ð_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( @@ -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(); @@ -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(); diff --git a/docs/3_guides/1.2_SDK_api_reference.md b/docs/3_guides/1.2_SDK_api_reference.md index c9622305a1..4d165b3c95 100644 --- a/docs/3_guides/1.2_SDK_api_reference.md +++ b/docs/3_guides/1.2_SDK_api_reference.md @@ -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 ``` #### 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 @@ -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. diff --git a/docs/3_guides/9_aligned_cli.md b/docs/3_guides/9_aligned_cli.md index 9c8a50dda2..e31599ef24 100644 --- a/docs/3_guides/9_aligned_cli.md +++ b/docs/3_guides/9_aligned_cli.md @@ -72,7 +72,7 @@ Submit a proof to the Aligned Layer batcher. - `--keystore_path `: Path to the local keystore. - `--private_key `: User's wallet private key. - `--nonce `: 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 `: Network name to interact with. - Default: `devnet`