diff --git a/config-files/config-batcher-docker.yaml b/config-files/config-batcher-docker.yaml index b50f1ecaae..3894a90e54 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: 32 + # 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..adb16f32ae 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: 32 + # 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..619ed19322 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: 32 + # 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/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..25c3650070 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,59 @@ 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(()); + }; + + // When pre-verification is enabled, batcher will verify proofs for faster feedback with clients + if self.pre_verification_is_enabled { + let verification_data = &nonced_verification_data.verification_data; + if self + .is_verifier_disabled(verification_data.proving_system) + .await + { + warn!( + "Verifier for proving system {} is disabled, skipping verification", + verification_data.proving_system + ); + send_message( + ws_conn_sink.clone(), + SubmitProofResponseMessage::InvalidProof(ProofInvalidReason::DisabledVerifier( + verification_data.proving_system, + )), + ) + .await; + self.metrics.user_error(&[ + "disabled_verifier", + &format!("{}", verification_data.proving_system), + ]); + return Ok(()); + } + + if !zk_utils::verify(verification_data).await { + error!("Invalid proof detected. Verification failed"); + send_message( + ws_conn_sink.clone(), + SubmitProofResponseMessage::InvalidProof(ProofInvalidReason::RejectedProof), + ) + .await; + self.metrics.user_error(&[ + "rejected_proof", + &format!("{}", verification_data.proving_system), + ]); + 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. @@ -958,6 +1017,13 @@ impl Batcher { return Ok(()); }; + // For now on until the message is fully processed, the batch state is locked + // This is needed because we need to query the user state to make validations and + // finally add the proof to the batch queue. + + let _batch_state_lock = self.batch_state.lock().await; + + let msg_max_fee = nonced_verification_data.max_fee; let user_last_max_fee_limit = user_state_guard.last_max_fee_limit; @@ -1223,17 +1289,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 +1954,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::compute_fee_per_proof_formula( + 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 +2567,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..5711deef00 100644 --- a/crates/sdk/src/verification_layer/mod.rs +++ b/crates/sdk/src/verification_layer/mod.rs @@ -171,20 +171,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 = compute_fee_per_proof_formula(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 compute_fee_per_proof_formula(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( 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`