diff --git a/node/CHANGELOG.md b/node/CHANGELOG.md index 0fd7a187e9..d667dcbed5 100644 --- a/node/CHANGELOG.md +++ b/node/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Enforce `min_tx_gas` in mempool preverify, reject TXs where `gas_limit < max(min_gas_limit, min_tx_gas)` [#3940] + ## [1.4.1] - 2025-12-04 ### Added @@ -76,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - First `dusk-node` release +[#3940]: https://github.com/dusk-network/rusk/issues/3940 [#3917]: https://github.com/dusk-network/rusk/issues/3917 [#3874]: https://github.com/dusk-network/rusk/issues/3874 [#3871]: https://github.com/dusk-network/rusk/issues/3871 diff --git a/node/src/mempool.rs b/node/src/mempool.rs index 00f002ac29..2bc5ad8617 100644 --- a/node/src/mempool.rs +++ b/node/src/mempool.rs @@ -349,10 +349,16 @@ impl MempoolSrv { dusk_consensus::validate_blob_sidecars(tx)?; } - // Check global minimum gas limit - let min_gas_limit = vm.min_gas_limit(); - if tx.inner.gas_limit() < min_gas_limit { - return Err(TxAcceptanceError::GasLimitTooLow(min_gas_limit)); + // Check global minimum gas limit and per-tx gas floor + let chain_min_gas_limit = vm.min_gas_limit(); + let min_tx_gas = vm.min_tx_gas(tip_height); + let required_gas_limit = + core::cmp::max(chain_min_gas_limit, min_tx_gas); + + if tx.inner.gas_limit() < required_gas_limit { + return Err(TxAcceptanceError::GasLimitTooLow( + required_gas_limit, + )); } } diff --git a/node/src/vm.rs b/node/src/vm.rs index e9c97480c5..e3fdcc38d2 100644 --- a/node/src/vm.rs +++ b/node/src/vm.rs @@ -98,6 +98,7 @@ pub trait VMExecution: Send + Sync + 'static { fn wasm64_disabled(&self, block_height: u64) -> bool; fn wasm32_disabled(&self, block_height: u64) -> bool; fn third_party_disabled(&self, block_height: u64) -> bool; + fn min_tx_gas(&self, height: u64) -> u64; } #[allow(clippy::large_enum_variant)] diff --git a/rusk/CHANGELOG.md b/rusk/CHANGELOG.md index 60f387b82a..be912e3249 100644 --- a/rusk/CHANGELOG.md +++ b/rusk/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add TX gas floor feature, including for well-known chain ids [#3940] + ## [1.4.1] - 2025-12-04 ### Added @@ -406,6 +410,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add build system that generates keys for circuits and caches them. +[#3940]: https://github.com/dusk-network/rusk/issues/3940 [#3917]: https://github.com/dusk-network/rusk/issues/3917 [#3897]: https://github.com/dusk-network/rusk/issues/3897 [#3894]: https://github.com/dusk-network/rusk/issues/3894 diff --git a/rusk/src/lib/node/vm.rs b/rusk/src/lib/node/vm.rs index dff2278e1a..2e57d29dfb 100644 --- a/rusk/src/lib/node/vm.rs +++ b/rusk/src/lib/node/vm.rs @@ -321,6 +321,19 @@ impl VMExecution for Rusk { .map(|activation| activation.is_active_at(block_height)) .unwrap_or(false) } + + fn min_tx_gas(&self, height: u64) -> u64 { + self.vm_config + .feature(FEATURE_MIN_TX_GAS) + .and_then(|activation| { + if activation.is_active_at(height) { + self.vm_config.min_tx_gas + } else { + None + } + }) + .unwrap_or(0) + } } fn has_unique_elements(iter: T) -> bool diff --git a/rusk/src/lib/node/vm/config.rs b/rusk/src/lib/node/vm/config.rs index d63dc00190..ec0a5d4236 100644 --- a/rusk/src/lib/node/vm/config.rs +++ b/rusk/src/lib/node/vm/config.rs @@ -21,6 +21,7 @@ const DEFAULT_GAS_PER_BLOB: u64 = 1_000_000; const DEFAULT_MIN_DEPLOY_POINTS: u64 = 5_000_000; const DEFAULT_MIN_DEPLOYMENT_GAS_PRICE: u64 = 2_000; const DEFAULT_BLOCK_GAS_LIMIT: u64 = 3 * 1_000_000_000; +const DEFAULT_MIN_TX_GAS: u64 = 5_000_000; /// Configuration for the execution of a transaction. #[derive(Debug, Clone, serde::Serialize)] @@ -45,6 +46,9 @@ pub struct Config { #[serde(with = "humantime_serde")] pub generation_timeout: Option, + /// Optional minimum gas charged for any transaction. + pub min_tx_gas: Option, + /// Set of features to activate features: HashMap, } @@ -61,6 +65,7 @@ pub(crate) mod feature { pub const FEATURE_DISABLE_WASM64: &str = "DISABLE_WASM64"; pub const FEATURE_DISABLE_WASM32: &str = "DISABLE_WASM32"; pub const FEATURE_DISABLE_3RD_PARTY: &str = "DISABLE_3RD_PARTY"; + pub const FEATURE_MIN_TX_GAS: &str = "MIN_TX_GAS"; pub const HQ_KECCAK256: &str = "HQ_KECCAK256"; } @@ -73,6 +78,7 @@ impl Config { min_deployment_gas_price: DEFAULT_MIN_DEPLOYMENT_GAS_PRICE, min_deploy_points: DEFAULT_MIN_DEPLOY_POINTS, block_gas_limit: DEFAULT_BLOCK_GAS_LIMIT, + min_tx_gas: None, generation_timeout: None, features: HashMap::new(), } @@ -121,6 +127,12 @@ impl Config { self } + /// Set the minimum gas charged for any transaction. + pub const fn with_min_tx_gas(mut self, min_tx_gas: u64) -> Self { + self.min_tx_gas = Some(min_tx_gas); + self + } + /// Create a new `Config` with the given parameters. pub fn to_execution_config(&self, block_height: u64) -> ExecutionConfig { let with_public_sender: bool = self @@ -143,11 +155,22 @@ impl Config { .feature(feature::FEATURE_DISABLE_3RD_PARTY) .map(|activation| activation.is_active_at(block_height)) .unwrap_or_default(); + let min_tx_gas = if self + .feature(feature::FEATURE_MIN_TX_GAS) + .map(|activation| activation.is_active_at(block_height)) + .unwrap_or_default() + { + self.min_tx_gas.unwrap_or(0) + } else { + 0 + }; + ExecutionConfig { gas_per_blob: self.gas_per_blob, gas_per_deploy_byte: self.gas_per_deploy_byte, min_deploy_points: self.min_deploy_points, min_deploy_gas_price: self.min_deployment_gas_price, + min_tx_gas, with_public_sender, with_blob, disable_wasm64, diff --git a/rusk/src/lib/node/vm/config/known.rs b/rusk/src/lib/node/vm/config/known.rs index a5d77e5f84..f503af6ca8 100644 --- a/rusk/src/lib/node/vm/config/known.rs +++ b/rusk/src/lib/node/vm/config/known.rs @@ -10,7 +10,9 @@ use std::sync::LazyLock; use dusk_vm::FeatureActivation; -use crate::node::{FEATURE_DISABLE_3RD_PARTY, FEATURE_DISABLE_WASM32}; +use crate::node::{ + FEATURE_DISABLE_3RD_PARTY, FEATURE_DISABLE_WASM32, FEATURE_MIN_TX_GAS, +}; use super::feature::{ FEATURE_ABI_PUBLIC_SENDER, FEATURE_BLOB, FEATURE_DISABLE_WASM64, @@ -19,6 +21,7 @@ use super::feature::{ use super::{ DEFAULT_BLOCK_GAS_LIMIT, DEFAULT_GAS_PER_BLOB, DEFAULT_GAS_PER_DEPLOY_BYTE, DEFAULT_MIN_DEPLOYMENT_GAS_PRICE, DEFAULT_MIN_DEPLOY_POINTS, + DEFAULT_MIN_TX_GAS, }; pub const MAINNET_ID: u8 = 1; @@ -36,7 +39,8 @@ pub struct WellKnownConfig { pub min_deploy_points: u64, pub min_deployment_gas_price: u64, pub block_gas_limit: u64, - pub features: [(&'static str, FeatureActivation); 6], + pub min_tx_gas: Option, + pub features: [(&'static str, FeatureActivation); 7], } impl WellKnownConfig { @@ -87,6 +91,7 @@ static MAINNET_CONFIG: LazyLock = min_deploy_points: DEFAULT_MIN_DEPLOY_POINTS, min_deployment_gas_price: DEFAULT_MIN_DEPLOYMENT_GAS_PRICE, block_gas_limit: DEFAULT_BLOCK_GAS_LIMIT, + min_tx_gas: None, features: [ (FEATURE_ABI_PUBLIC_SENDER, MAINNET_SENDER_ACTIVATION_HEIGHT), (HQ_KECCAK256, NEVER), @@ -94,6 +99,7 @@ static MAINNET_CONFIG: LazyLock = (FEATURE_DISABLE_WASM64, MAINNET_DISABLE_WASM_64.clone()), (FEATURE_DISABLE_WASM32, MAINNET_3RD_PARTY_OFF.clone()), (FEATURE_DISABLE_3RD_PARTY, MAINNET_3RD_PARTY_OFF.clone()), + (FEATURE_MIN_TX_GAS, NEVER), ], }); @@ -108,6 +114,7 @@ const TESTNET_CONFIG: WellKnownConfig = WellKnownConfig { min_deploy_points: DEFAULT_MIN_DEPLOY_POINTS, min_deployment_gas_price: DEFAULT_MIN_DEPLOYMENT_GAS_PRICE, block_gas_limit: DEFAULT_BLOCK_GAS_LIMIT, + min_tx_gas: None, features: [ (FEATURE_ABI_PUBLIC_SENDER, GENESIS), (HQ_KECCAK256, NEVER), @@ -115,6 +122,7 @@ const TESTNET_CONFIG: WellKnownConfig = WellKnownConfig { (FEATURE_DISABLE_WASM64, TESTNET_AT_12_11_2025_AT_09_00_UTC), (FEATURE_DISABLE_WASM32, NEVER), (FEATURE_DISABLE_3RD_PARTY, NEVER), + (FEATURE_MIN_TX_GAS, NEVER), ], }; @@ -125,6 +133,7 @@ const DEVNET_CONFIG: WellKnownConfig = WellKnownConfig { min_deploy_points: DEFAULT_MIN_DEPLOY_POINTS, min_deployment_gas_price: DEFAULT_MIN_DEPLOYMENT_GAS_PRICE, block_gas_limit: DEFAULT_BLOCK_GAS_LIMIT, + min_tx_gas: None, features: [ (FEATURE_ABI_PUBLIC_SENDER, GENESIS), (HQ_KECCAK256, GENESIS), @@ -132,6 +141,7 @@ const DEVNET_CONFIG: WellKnownConfig = WellKnownConfig { (FEATURE_DISABLE_WASM64, GENESIS), (FEATURE_DISABLE_WASM32, NEVER), (FEATURE_DISABLE_3RD_PARTY, NEVER), + (FEATURE_MIN_TX_GAS, NEVER), ], }; @@ -142,6 +152,7 @@ const LOCALNET_CONFIG: WellKnownConfig = WellKnownConfig { min_deploy_points: DEFAULT_MIN_DEPLOY_POINTS, min_deployment_gas_price: DEFAULT_MIN_DEPLOYMENT_GAS_PRICE, block_gas_limit: DEFAULT_BLOCK_GAS_LIMIT, + min_tx_gas: Some(DEFAULT_MIN_TX_GAS), features: [ (FEATURE_ABI_PUBLIC_SENDER, GENESIS), (HQ_KECCAK256, GENESIS), @@ -149,5 +160,6 @@ const LOCALNET_CONFIG: WellKnownConfig = WellKnownConfig { (FEATURE_DISABLE_WASM64, GENESIS), (FEATURE_DISABLE_WASM32, NEVER), (FEATURE_DISABLE_3RD_PARTY, NEVER), + (FEATURE_MIN_TX_GAS, GENESIS), ], }; diff --git a/rusk/src/lib/node/vm/config/opt.rs b/rusk/src/lib/node/vm/config/opt.rs index 394052da8a..60238e79a0 100644 --- a/rusk/src/lib/node/vm/config/opt.rs +++ b/rusk/src/lib/node/vm/config/opt.rs @@ -43,6 +43,9 @@ pub struct OptionalConfig { #[serde(default, with = "humantime_serde")] pub generation_timeout: Option, + /// Minimum gas charged for any transaction. + pub min_tx_gas: Option, + /// Set of features to activate #[serde(default)] features: HashMap, @@ -111,6 +114,10 @@ impl OptionalConfig { config.block_gas_limit, ); + if let Some(value) = config.min_tx_gas { + Self::set_or_warn("min_tx_gas", &mut self.min_tx_gas, value); + } + for (feature, activation) in &config.features { if let Some(v) = self.feature(feature) { if v != activation { @@ -192,6 +199,7 @@ impl TryFrom for Config { block_gas_limit: value .block_gas_limit .ok_or(anyhow!("Missing block_gas_limit"))?, + min_tx_gas: value.min_tx_gas, generation_timeout: value.generation_timeout, features: value.features, }) diff --git a/rusk/tests/services/gas_behavior.rs b/rusk/tests/services/gas_behavior.rs index 403546cbae..918787fc31 100644 --- a/rusk/tests/services/gas_behavior.rs +++ b/rusk/tests/services/gas_behavior.rs @@ -20,7 +20,7 @@ use tempfile::tempdir; use tracing::info; use crate::common::logger; -use crate::common::state::{generator_procedure, new_state}; +use crate::common::state::{generator_procedure, new_state, ExecuteResult}; use crate::common::wallet::{ test_wallet as wallet, TestStateClient, TestStore, }; @@ -34,6 +34,8 @@ const GAS_LIMIT_0: u64 = 100_000_000; const GAS_LIMIT_1: u64 = 300_000_000; const GAS_PRICE: u64 = 1; const DEPOSIT: u64 = 0; +const MIN_TX_GAS: u64 = 5_000_000; +const LOW_GAS_LIMIT: u64 = 1_000_000; // Creates the Rusk initial state for the tests below async fn initial_state>(dir: P) -> Result { @@ -130,6 +132,13 @@ fn make_transactions( tx_1.gas_spent < GAS_LIMIT_1, "Successful transaction should consume less than provided" ); + assert!( + tx_1.gas_spent >= MIN_TX_GAS, + "Successful transaction should be charged at least MIN_TX_GAS \ + (got {}, expected >= {})", + tx_1.gas_spent, + MIN_TX_GAS, + ); } #[tokio::test(flavor = "multi_thread")] @@ -172,3 +181,56 @@ pub async fn erroring_tx_charged_full() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +pub async fn tx_below_min_tx_gas_is_discarded() -> Result<()> { + // Setup the logger + logger(); + + let tmp = tempdir().expect("Should be able to create temporary directory"); + let rusk = initial_state(&tmp).await?; + + let cache = Arc::new(RwLock::new(HashMap::new())); + + let wallet = wallet::Wallet::new( + TestStore, + TestStateClient { + rusk: rusk.clone(), + cache, + }, + ); + + let mut rng = StdRng::seed_from_u64(0xbeef); + + // This is the same call as in make_transactions, but with a gas_limit + // that is below the MIN_TX_GAS + let contract_call = ContractCall::new(TRANSFER_CONTRACT, "root"); + let tx_low = wallet + .phoenix_execute( + &mut rng, + SENDER_INDEX_0, + LOW_GAS_LIMIT, + GAS_PRICE, + DEPOSIT, + TransactionData::Call(contract_call), + ) + .expect("Making the transaction should succeed"); + + // With the MIN_TX_GAS feature active, this tx must be discarded + // during block generation + let res = generator_procedure( + &rusk, + &[tx_low], + BLOCK_HEIGHT, + BLOCK_GAS_LIMIT, + vec![], + Some(ExecuteResult { + executed: 0, + discarded: 1, + }), + ); + + res.expect("generator procedure should succeed"); + + Ok(()) +} diff --git a/vm/CHANGELOG.md b/vm/CHANGELOG.md index 53a02dbcc9..5ef0be3561 100644 --- a/vm/CHANGELOG.md +++ b/vm/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Enforce a per‑transaction minimum gas floor [#3940] + ## [1.4.1] - 2025-12-04 ### Added @@ -58,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add vm to interact with Dusk network [#3235] +[#3940]: https://github.com/dusk-network/rusk/issues/3940 [#3774]: https://github.com/dusk-network/rusk/issues/3774 [#3235]: https://github.com/dusk-network/rusk/issues/3235 [#3341]: https://github.com/dusk-network/rusk/issues/3341 diff --git a/vm/src/execute.rs b/vm/src/execute.rs index 984a2bceeb..a086880173 100644 --- a/vm/src/execute.rs +++ b/vm/src/execute.rs @@ -69,6 +69,13 @@ pub fn execute( tx: &Transaction, config: &Config, ) -> Result, ContractError>>, Error> { + // Enforce gas_limit >= min_tx_gas if min_tx_gas feature is enabled + if config.min_tx_gas > 0 && tx.gas_limit() < config.min_tx_gas { + return Err(Error::Panic( + "transaction gas_limit is below the minimum required gas".into(), + )); + } + // Transaction will be discarded if it is a deployment transaction // with gas limit smaller than deploy charge. tx.deploy_check( @@ -149,6 +156,12 @@ pub fn execute( // Ensure all gas is consumed if there's an error in the contract call if receipt.data.is_err() { receipt.gas_spent = receipt.gas_limit; + } else if config.min_tx_gas > 0 { + // On success, enforce the global per-tx minimum gas floor if the + // feature min_tx_gas is enabled + if receipt.gas_spent < config.min_tx_gas { + receipt.gas_spent = config.min_tx_gas; + } } // Refund the appropriate amount to the transaction. This call is guaranteed diff --git a/vm/src/execute/config.rs b/vm/src/execute/config.rs index 7e5d630027..f9dd9ba7e3 100644 --- a/vm/src/execute/config.rs +++ b/vm/src/execute/config.rs @@ -32,6 +32,9 @@ pub struct Config { /// Disable calls to 3rd party contracts pub disable_3rd_party: bool, + + /// Minimum gas to charge for any transaction. 0 disables the floor + pub min_tx_gas: u64, } impl Default for Config { @@ -52,5 +55,6 @@ impl Config { disable_wasm64: false, disable_wasm32: false, disable_3rd_party: false, + min_tx_gas: 0, }; }