From 782321e5d0d956236f096c6e6cdfe1fd8a6b830f Mon Sep 17 00:00:00 2001 From: Gonza Montiel Date: Tue, 28 Oct 2025 11:06:45 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Implement=20dynamic=20fee?= =?UTF-8?q?=20adjustment=20(#251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ✨ Implement Dynamic Fee Adjustment Mechanism ## Overview Implements a dynamic fee adjustment mechanism, replacing the constant fee multiplier with an adaptive multiplier that responds to network congestion, following Moonbeam's pattern. ## Changes - Replaces `ConstFeeMultiplier` with `TargetedFeeAdjustment` across all runtime configurations (mainnet, stagenet, testnet) - Implements an EIP-1559-like slow-adjusting fee mechanism that prevents DoS attacks by adjusting fees based on block fullness - **Configurable Parameters**: - Target block fullness: 35% - Adjustment variable: 4/1000 (responds in ~1 hour at extreme congestion) - Two modes: - `SlowAdjustingFeeUpdate` for mainnet and testnet. - `FastAdjustingFeeUpdate` for stagenet. - Adds tests coverage for different fee scenarios ## Technical Details The fee adjustment algorithm works as follows: ``` diff = (previous_block_weight - target) / maximum_block_weight next_multiplier = prev_multiplier * (1 + (v * diff) + ((v * diff)^2 / 2)) assert(next_multiplier > min) ``` **Where:** - `v` = AdjustmentVariable - `target` = TargetBlockFullness - `min` = MinimumMultiplier `SlowAdjustingFeeUpdate` sets a minimum multiplier of `1x` for a conservative fee adjustment, while `FastAdjustingFeeUpdate` sets it to `0.1x`, which is mainly used for dev networks / testing. --- operator/runtime/mainnet/src/configs/mod.rs | 44 +++- .../runtime/mainnet/tests/fee_adjustment.rs | 227 ++++++++++++++++++ operator/runtime/mainnet/tests/lib.rs | 1 + operator/runtime/stagenet/src/configs/mod.rs | 42 +++- .../runtime/stagenet/tests/fee_adjustment.rs | 227 ++++++++++++++++++ operator/runtime/stagenet/tests/lib.rs | 1 + operator/runtime/testnet/src/configs/mod.rs | 45 +++- .../runtime/testnet/tests/fee_adjustment.rs | 227 ++++++++++++++++++ operator/runtime/testnet/tests/lib.rs | 1 + 9 files changed, 794 insertions(+), 21 deletions(-) create mode 100644 operator/runtime/mainnet/tests/fee_adjustment.rs create mode 100644 operator/runtime/stagenet/tests/fee_adjustment.rs create mode 100644 operator/runtime/testnet/tests/fee_adjustment.rs diff --git a/operator/runtime/mainnet/src/configs/mod.rs b/operator/runtime/mainnet/src/configs/mod.rs index be89debd..e3ef82aa 100644 --- a/operator/runtime/mainnet/src/configs/mod.rs +++ b/operator/runtime/mainnet/src/configs/mod.rs @@ -123,7 +123,7 @@ use pallet_evm::{ use pallet_grandpa::AuthorityId as GrandpaId; use pallet_im_online::sr25519::AuthorityId as ImOnlineId; use pallet_transaction_payment::{ - ConstFeeMultiplier, FungibleAdapter, Multiplier, Pallet as TransactionPayment, + FungibleAdapter, Multiplier, Pallet as TransactionPayment, TargetedFeeAdjustment, }; use polkadot_primitives::Moment; use runtime_params::RuntimeParameters; @@ -144,10 +144,8 @@ use sp_consensus_beefy::{ use sp_core::{crypto::KeyTypeId, Get, H160, H256, U256}; use sp_runtime::FixedU128; use sp_runtime::{ - traits::{ - Convert, ConvertInto, IdentityLookup, Keccak256, One, OpaqueKeys, UniqueSaturatedInto, - }, - FixedPointNumber, Perbill, + traits::{Convert, ConvertInto, IdentityLookup, Keccak256, OpaqueKeys, UniqueSaturatedInto}, + FixedPointNumber, Perbill, Perquintill, }; use sp_staking::{EraIndex, SessionIndex}; use sp_std::{ @@ -451,9 +449,41 @@ impl pallet_grandpa::Config for Runtime { } parameter_types! { - pub FeeMultiplier: Multiplier = Multiplier::one(); + /// The portion of the `NORMAL_DISPATCH_RATIO` that we adjust the fees with. Blocks filled less + /// than this will decrease the weight and more will increase. + pub const TargetBlockFullness: Perquintill = Perquintill::from_percent(35); + /// The adjustment variable of the runtime. Higher values will cause `TargetBlockFullness` to + /// change the fees more rapidly. This fast multiplier responds by doubling/halving in + /// approximately one hour at extreme block congestion levels. + pub AdjustmentVariable: Multiplier = Multiplier::saturating_from_rational(4, 1_000); + /// Minimum amount of the multiplier. This value cannot be too low. A test case should ensure + /// that combined with `AdjustmentVariable`, we can recover from the minimum. + /// See `multiplier_can_grow_from_zero` in integration_tests.rs. + pub MinimumMultiplier: Multiplier = Multiplier::from(1u128); + /// Maximum multiplier. We pick a value that is expensive but not impossibly so; it should act + /// as a safety net. + pub MaximumMultiplier: Multiplier = Multiplier::from(100_000u128); } +/// SlowAdjustingFeeUpdate implements a dynamic fee adjustment mechanism similar to Ethereum's EIP-1559. +/// It adjusts transaction fees based on network congestion to prevent DoS attacks. +/// This version uses a higher minimum multiplier for more conservative fee adjustments. +/// +/// The algorithm works as follows: +/// diff = (previous_block_weight - target) / maximum_block_weight +/// next_multiplier = prev_multiplier * (1 + (v * diff) + ((v * diff)^2 / 2)) +/// assert(next_multiplier > min) +/// where: v is AdjustmentVariable +/// target is TargetBlockFullness +/// min is MinimumMultiplier +pub type SlowAdjustingFeeUpdate = TargetedFeeAdjustment< + R, + TargetBlockFullness, + AdjustmentVariable, + MinimumMultiplier, + MaximumMultiplier, +>; + impl pallet_transaction_payment::Config for Runtime { type RuntimeEvent = RuntimeEvent; type OnChargeTransaction = FungibleAdapter< @@ -472,7 +502,7 @@ impl pallet_transaction_payment::Config for Runtime { type LengthToFee = IdentityFee; #[cfg(feature = "runtime-benchmarks")] type LengthToFee = benchmark_helpers::BenchmarkWeightToFee; - type FeeMultiplierUpdate = ConstFeeMultiplier; + type FeeMultiplierUpdate = SlowAdjustingFeeUpdate; type WeightInfo = mainnet_weights::pallet_transaction_payment::WeightInfo; } diff --git a/operator/runtime/mainnet/tests/fee_adjustment.rs b/operator/runtime/mainnet/tests/fee_adjustment.rs new file mode 100644 index 00000000..d02e8d49 --- /dev/null +++ b/operator/runtime/mainnet/tests/fee_adjustment.rs @@ -0,0 +1,227 @@ +// Copyright 2025 Moonbeam Foundation. +// This file is part of DataHaven. + +//! Fee adjustment integration tests for DataHaven mainnet runtime +//! Based on Moonbeam's fee adjustment tests + +use datahaven_mainnet_runtime::{ + configs::{ + MinimumMultiplier, RuntimeBlockWeights, SlowAdjustingFeeUpdate, TargetBlockFullness, + TransactionPaymentAsGasPrice, + }, + currency::WEIGHT_FEE, + Runtime, System, +}; +use datahaven_runtime_common::constants::gas::WEIGHT_PER_GAS; +use fp_evm::FeeCalculator; +use frame_support::pallet_prelude::DispatchClass; +use frame_support::traits::OnFinalize; +use sp_core::U256; +use sp_runtime::{traits::Convert, BuildStorage, FixedPointNumber, FixedU128, Perbill}; + +/// Helper function to run tests with a specific system weight +fn run_with_system_weight(w: frame_support::weights::Weight, mut assertions: F) +where + F: FnMut() -> (), +{ + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + System::set_block_consumed_resources(w, 0); + assertions() + }); +} + +#[test] +fn multiplier_can_grow_from_zero() { + let minimum_multiplier = MinimumMultiplier::get(); + let target = TargetBlockFullness::get() + * RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .max_total + .unwrap(); + // if the min is too small, then this will not change, and we are doomed forever. + // the weight is 1/100th bigger than target. + run_with_system_weight(target * 101 / 100, || { + let next = SlowAdjustingFeeUpdate::::convert(minimum_multiplier); + assert!( + next > minimum_multiplier, + "{:?} !>= {:?}", + next, + minimum_multiplier + ); + }) +} + +#[test] +fn fee_calculation() { + let base_extrinsic = RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .base_extrinsic; + let multiplier = FixedU128::from_float(0.999000000000000000); + let extrinsic_len = 100u32; + let extrinsic_weight = 5_000u64; + let tip = 42u128; + + // For IdentityFee, the fee is just the weight itself + // Formula: base_extrinsic + (multiplier * call_weight) + extrinsic_len + tip + let expected_fee = base_extrinsic.ref_time() as u128 + + (multiplier.saturating_mul_int(extrinsic_weight as u128)) + + extrinsic_len as u128 + + tip; + + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier); + let actual_fee = pallet_transaction_payment::Pallet::::compute_fee( + extrinsic_len, + &frame_support::dispatch::DispatchInfo { + class: DispatchClass::Normal, + pays_fee: frame_support::dispatch::Pays::Yes, + call_weight: frame_support::weights::Weight::from_parts(extrinsic_weight, 1), + extension_weight: frame_support::weights::Weight::zero(), + }, + tip, + ); + + assert_eq!( + expected_fee, actual_fee, + "The actual fee did not match the expected fee, expected: {}, actual: {}", + expected_fee, actual_fee + ); + }); +} + +#[test] +fn min_gas_price_is_deterministic() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let multiplier = FixedU128::from_u32(1); + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier); + let actual = TransactionPaymentAsGasPrice::min_gas_price().0; + let expected: U256 = multiplier + .saturating_mul_int(WEIGHT_FEE.saturating_mul(WEIGHT_PER_GAS as u128)) + .into(); + + assert_eq!(expected, actual); + }); +} + +#[test] +fn min_gas_price_has_no_precision_loss_from_saturating_mul_int() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let multiplier_1 = FixedU128::from_float(0.999593900000000000); + let multiplier_2 = FixedU128::from_float(0.999593200000000000); + + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier_1); + let a = TransactionPaymentAsGasPrice::min_gas_price(); + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier_2); + let b = TransactionPaymentAsGasPrice::min_gas_price(); + + assert_ne!( + a, b, + "both gas prices were equal, unexpected precision loss incurred" + ); + }); +} + +#[test] +fn fee_scenarios() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let weight_fee_per_gas = WEIGHT_FEE.saturating_mul(WEIGHT_PER_GAS as u128); + let sim = |start_gas_price: u128, fullness: Perbill, num_blocks: u64| -> U256 { + let start_multiplier = FixedU128::from_rational(start_gas_price, weight_fee_per_gas); + pallet_transaction_payment::NextFeeMultiplier::::set(start_multiplier); + + let normal_weight = RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .max_total + .unwrap(); + let block_weight = normal_weight * fullness; + + for i in 0..num_blocks { + System::set_block_number(i as u32); + System::set_block_consumed_resources(block_weight, 0); + pallet_transaction_payment::Pallet::::on_finalize(i as u32); + } + + TransactionPaymentAsGasPrice::min_gas_price().0 + }; + + // The expected values are the ones observed during test execution, + // they are expected to change when parameters that influence + // the fee calculation are changed, and should be updated accordingly. + // If a test fails when nothing specific to fees has changed, + // it may indicate an unexpected collateral effect and should be investigated + + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 1), + U256::from(31_250_000_000u128), // lower bound enforced + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 1), + U256::from(31_250_000_000u128), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 1), + U256::from(31_268_755_625u128), // slightly higher than lower bound + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 1), + U256::from(31_331_355_625u128), // a bit higher than before + ); + + // 1 "real" hour (at 12-second blocks) + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 600), + U256::from(31_250_000_000u128), // lower bound enforced + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 600), + U256::from(31_250_000_000u128), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 600), + U256::from(44_791_543_237u128), // DataHaven specific value + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 600), + U256::from(148_712_903_041u128), // DataHaven specific value + ); + + // 1 "real" day (at 12-second blocks) + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 14400), + U256::from(31_250_000_000u128), // lower bound enforced + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 14400), + U256::from(31_250_000_000u128), // lower bound enforced if threshold not reached + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 14400), + U256::from(176_666_465_470_908u128), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 14400), + U256::from(3_125_000_000_000_000u128), + // upper bound enforced (min_gas_price * MaximumMultiplier) + ); + }); +} diff --git a/operator/runtime/mainnet/tests/lib.rs b/operator/runtime/mainnet/tests/lib.rs index e94b5930..921ad901 100644 --- a/operator/runtime/mainnet/tests/lib.rs +++ b/operator/runtime/mainnet/tests/lib.rs @@ -1,6 +1,7 @@ //! Integration tests for DataHaven mainnet runtime pub mod common; +mod fee_adjustment; pub mod governance; mod migrations; mod native_token_transfer; diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index 863ec3d8..572d943f 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -123,7 +123,7 @@ use pallet_evm::{ use pallet_grandpa::AuthorityId as GrandpaId; use pallet_im_online::sr25519::AuthorityId as ImOnlineId; use pallet_transaction_payment::{ - ConstFeeMultiplier, FungibleAdapter, Multiplier, Pallet as TransactionPayment, + FungibleAdapter, Multiplier, Pallet as TransactionPayment, TargetedFeeAdjustment, }; use polkadot_primitives::Moment; use runtime_params::RuntimeParameters; @@ -144,10 +144,8 @@ use sp_consensus_beefy::{ use sp_core::{crypto::KeyTypeId, Get, H160, H256, U256}; use sp_runtime::FixedU128; use sp_runtime::{ - traits::{ - Convert, ConvertInto, IdentityLookup, Keccak256, One, OpaqueKeys, UniqueSaturatedInto, - }, - FixedPointNumber, Perbill, + traits::{Convert, ConvertInto, IdentityLookup, Keccak256, OpaqueKeys, UniqueSaturatedInto}, + FixedPointNumber, Perbill, Perquintill, }; use sp_staking::{EraIndex, SessionIndex}; use sp_std::{ @@ -450,9 +448,39 @@ impl pallet_grandpa::Config for Runtime { } parameter_types! { - pub FeeMultiplier: Multiplier = Multiplier::one(); + /// The portion of the `NORMAL_DISPATCH_RATIO` that we adjust the fees with. Blocks filled less + /// than this will decrease the weight and more will increase. + pub const TargetBlockFullness: Perquintill = Perquintill::from_percent(35); + /// The adjustment variable of the runtime. Higher values will cause `TargetBlockFullness` to + /// change the fees more rapidly. This low value causes changes to occur slowly over time. + pub AdjustmentVariable: Multiplier = Multiplier::saturating_from_rational(4, 1_000); + /// Minimum amount of the multiplier. This value cannot be too low. A test case should ensure + /// that combined with `AdjustmentVariable`, we can recover from the minimum. + /// See `multiplier_can_grow_from_zero` in integration_tests.rs. + pub MinimumMultiplier: Multiplier = Multiplier::saturating_from_rational(1, 10); + /// Maximum multiplier. We pick a value that is expensive but not impossibly so; it should act + /// as a safety net. + pub MaximumMultiplier: Multiplier = Multiplier::from(100_000u128); } +/// FastAdjustingFeeUpdate implements a dynamic fee adjustment mechanism similar to Ethereum's EIP-1559. +/// It adjusts transaction fees based on network congestion to prevent DoS attacks. +/// +/// The algorithm works as follows: +/// diff = (previous_block_weight - target) / maximum_block_weight +/// next_multiplier = prev_multiplier * (1 + (v * diff) + ((v * diff)^2 / 2)) +/// assert(next_multiplier > min) +/// where: v is AdjustmentVariable +/// target is TargetBlockFullness +/// min is MinimumMultiplier +pub type FastAdjustingFeeUpdate = TargetedFeeAdjustment< + R, + TargetBlockFullness, + AdjustmentVariable, + MinimumMultiplier, + MaximumMultiplier, +>; + impl pallet_transaction_payment::Config for Runtime { type RuntimeEvent = RuntimeEvent; type OnChargeTransaction = FungibleAdapter< @@ -471,7 +499,7 @@ impl pallet_transaction_payment::Config for Runtime { type LengthToFee = IdentityFee; #[cfg(feature = "runtime-benchmarks")] type LengthToFee = benchmark_helpers::BenchmarkWeightToFee; - type FeeMultiplierUpdate = ConstFeeMultiplier; + type FeeMultiplierUpdate = FastAdjustingFeeUpdate; type WeightInfo = stagenet_weights::pallet_transaction_payment::WeightInfo; } diff --git a/operator/runtime/stagenet/tests/fee_adjustment.rs b/operator/runtime/stagenet/tests/fee_adjustment.rs new file mode 100644 index 00000000..f6ea41a6 --- /dev/null +++ b/operator/runtime/stagenet/tests/fee_adjustment.rs @@ -0,0 +1,227 @@ +// Copyright 2025 Moonbeam Foundation. +// This file is part of DataHaven. + +//! Fee adjustment integration tests for DataHaven stagenet runtime +//! Based on Moonbeam's fee adjustment tests + +use datahaven_runtime_common::constants::gas::WEIGHT_PER_GAS; +use datahaven_stagenet_runtime::{ + configs::{ + FastAdjustingFeeUpdate, MinimumMultiplier, RuntimeBlockWeights, TargetBlockFullness, + TransactionPaymentAsGasPrice, + }, + currency::WEIGHT_FEE, + Runtime, System, +}; +use fp_evm::FeeCalculator; +use frame_support::pallet_prelude::DispatchClass; +use frame_support::traits::OnFinalize; +use sp_core::U256; +use sp_runtime::{traits::Convert, BuildStorage, FixedPointNumber, FixedU128, Perbill}; + +/// Helper function to run tests with a specific system weight +fn run_with_system_weight(w: frame_support::weights::Weight, mut assertions: F) +where + F: FnMut() -> (), +{ + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + System::set_block_consumed_resources(w, 0); + assertions() + }); +} + +#[test] +fn multiplier_can_grow_from_zero() { + let minimum_multiplier = MinimumMultiplier::get(); + let target = TargetBlockFullness::get() + * RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .max_total + .unwrap(); + // if the min is too small, then this will not change, and we are doomed forever. + // the weight is 1/100th bigger than target. + run_with_system_weight(target * 101 / 100, || { + let next = FastAdjustingFeeUpdate::::convert(minimum_multiplier); + assert!( + next > minimum_multiplier, + "{:?} !>= {:?}", + next, + minimum_multiplier + ); + }) +} + +#[test] +fn fee_calculation() { + let base_extrinsic = RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .base_extrinsic; + let multiplier = FixedU128::from_float(0.999000000000000000); + let extrinsic_len = 100u32; + let extrinsic_weight = 5_000u64; + let tip = 42u128; + + // For IdentityFee, the fee is just the weight itself + // Formula: base_extrinsic + (multiplier * call_weight) + extrinsic_len + tip + let expected_fee = base_extrinsic.ref_time() as u128 + + (multiplier.saturating_mul_int(extrinsic_weight as u128)) + + extrinsic_len as u128 + + tip; + + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier); + let actual_fee = pallet_transaction_payment::Pallet::::compute_fee( + extrinsic_len, + &frame_support::dispatch::DispatchInfo { + class: DispatchClass::Normal, + pays_fee: frame_support::dispatch::Pays::Yes, + call_weight: frame_support::weights::Weight::from_parts(extrinsic_weight, 1), + extension_weight: frame_support::weights::Weight::zero(), + }, + tip, + ); + + assert_eq!( + expected_fee, actual_fee, + "The actual fee did not match the expected fee, expected: {}, actual: {}", + expected_fee, actual_fee + ); + }); +} + +#[test] +fn min_gas_price_is_deterministic() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let multiplier = FixedU128::from_u32(1); + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier); + let actual = TransactionPaymentAsGasPrice::min_gas_price().0; + let expected: U256 = multiplier + .saturating_mul_int(WEIGHT_FEE.saturating_mul(WEIGHT_PER_GAS as u128)) + .into(); + + assert_eq!(expected, actual); + }); +} + +#[test] +fn min_gas_price_has_no_precision_loss_from_saturating_mul_int() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let multiplier_1 = FixedU128::from_float(0.999593900000000000); + let multiplier_2 = FixedU128::from_float(0.999593200000000000); + + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier_1); + let a = TransactionPaymentAsGasPrice::min_gas_price(); + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier_2); + let b = TransactionPaymentAsGasPrice::min_gas_price(); + + assert_ne!( + a, b, + "both gas prices were equal, unexpected precision loss incurred" + ); + }); +} + +#[test] +fn fee_scenarios() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let weight_fee_per_gas = WEIGHT_FEE.saturating_mul(WEIGHT_PER_GAS as u128); + let sim = |start_gas_price: u128, fullness: Perbill, num_blocks: u64| -> U256 { + let start_multiplier = FixedU128::from_rational(start_gas_price, weight_fee_per_gas); + pallet_transaction_payment::NextFeeMultiplier::::set(start_multiplier); + + let normal_weight = RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .max_total + .unwrap(); + let block_weight = normal_weight * fullness; + + for i in 0..num_blocks { + System::set_block_number(i as u32); + System::set_block_consumed_resources(block_weight, 0); + pallet_transaction_payment::Pallet::::on_finalize(i as u32); + } + + TransactionPaymentAsGasPrice::min_gas_price().0 + }; + + // The expected values are the ones observed during test execution, + // they are expected to change when parameters that influence + // the fee calculation are changed, and should be updated accordingly. + // If a test fails when nothing specific to fees has changed, + // it may indicate an unexpected collateral effect and should be investigated + + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 1), + U256::from(998_600_980), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 1), + U256::from(999_600_080), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 1), + U256::from(1_000_600_180), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 1), + U256::from(1_002_603_380), + ); + + // 1 "real" hour (at 6-second blocks) + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 600), + U256::from(431_710_642), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 600), + U256::from(786_627_866), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 600), + U256::from(1_433_329_383u128), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 600), + U256::from(4_758_812_897u128), + ); + + // 1 "real" day (at 6-second blocks) + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 14400), + U256::from(31_250_000), // lower bound enforced + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 14400), + U256::from(31_250_000), // lower bound enforced if threshold not reached + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 14400), + U256::from(5_653_326_895_069u128), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 14400), + U256::from(31_250_000_000_000u128), + // upper bound enforced (min_gas_price * MaximumMultiplier) + ); + }); +} diff --git a/operator/runtime/stagenet/tests/lib.rs b/operator/runtime/stagenet/tests/lib.rs index 3766015d..f7455340 100644 --- a/operator/runtime/stagenet/tests/lib.rs +++ b/operator/runtime/stagenet/tests/lib.rs @@ -1,6 +1,7 @@ //! Integration tests for DataHaven stagenet runtime pub mod common; +mod fee_adjustment; pub mod governance; mod native_token_transfer; mod proxy; diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index 85e683e9..81202632 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -123,7 +123,7 @@ use pallet_evm::{ use pallet_grandpa::AuthorityId as GrandpaId; use pallet_im_online::sr25519::AuthorityId as ImOnlineId; use pallet_transaction_payment::{ - ConstFeeMultiplier, FungibleAdapter, Multiplier, Pallet as TransactionPayment, + FungibleAdapter, Multiplier, Pallet as TransactionPayment, TargetedFeeAdjustment, }; use polkadot_primitives::Moment; use runtime_params::RuntimeParameters; @@ -144,10 +144,8 @@ use sp_consensus_beefy::{ use sp_core::{crypto::KeyTypeId, Get, H160, H256, U256}; use sp_runtime::FixedU128; use sp_runtime::{ - traits::{ - Convert, ConvertInto, IdentityLookup, Keccak256, One, OpaqueKeys, UniqueSaturatedInto, - }, - FixedPointNumber, Perbill, + traits::{Convert, ConvertInto, IdentityLookup, Keccak256, OpaqueKeys, UniqueSaturatedInto}, + FixedPointNumber, Perbill, Perquintill, }; use sp_staking::{EraIndex, SessionIndex}; use sp_std::{ @@ -450,9 +448,42 @@ impl pallet_grandpa::Config for Runtime { } parameter_types! { - pub FeeMultiplier: Multiplier = Multiplier::one(); + /// The portion of the `NORMAL_DISPATCH_RATIO` that we adjust the fees with. Blocks filled less + /// than this will decrease the weight and more will increase. + pub const TargetBlockFullness: Perquintill = Perquintill::from_percent(35); + /// The adjustment variable of the runtime. Higher values will cause `TargetBlockFullness` to + /// change the fees more rapidly. This low value causes changes to occur slowly over time. + pub AdjustmentVariable: Multiplier = Multiplier::saturating_from_rational(4, 1_000); + /// Minimum amount of the multiplier. This value cannot be too low. A test case should ensure + /// that combined with `AdjustmentVariable`, we can recover from the minimum. + /// See `multiplier_can_grow_from_zero` in integration_tests.rs. + /// This value is currently only used by pallet-transaction-payment as an assertion that the + /// next multiplier is always > min value. + pub MinimumMultiplier: Multiplier = Multiplier::from(1u128); + /// Maximum multiplier. We pick a value that is expensive but not impossibly so; it should act + /// as a safety net. + pub MaximumMultiplier: Multiplier = Multiplier::from(100_000u128); } +/// SlowAdjustingFeeUpdate implements a dynamic fee adjustment mechanism similar to Ethereum's EIP-1559. +/// It adjusts transaction fees based on network congestion to prevent DoS attacks. +/// This version uses a higher minimum multiplier for more conservative fee adjustments. +/// +/// The algorithm works as follows: +/// diff = (previous_block_weight - target) / maximum_block_weight +/// next_multiplier = prev_multiplier * (1 + (v * diff) + ((v * diff)^2 / 2)) +/// assert(next_multiplier > min) +/// where: v is AdjustmentVariable +/// target is TargetBlockFullness +/// min is MinimumMultiplier +pub type SlowAdjustingFeeUpdate = TargetedFeeAdjustment< + R, + TargetBlockFullness, + AdjustmentVariable, + MinimumMultiplier, + MaximumMultiplier, +>; + impl pallet_transaction_payment::Config for Runtime { type RuntimeEvent = RuntimeEvent; type OnChargeTransaction = FungibleAdapter< @@ -471,7 +502,7 @@ impl pallet_transaction_payment::Config for Runtime { type LengthToFee = IdentityFee; #[cfg(feature = "runtime-benchmarks")] type LengthToFee = benchmark_helpers::BenchmarkWeightToFee; - type FeeMultiplierUpdate = ConstFeeMultiplier; + type FeeMultiplierUpdate = SlowAdjustingFeeUpdate; type WeightInfo = testnet_weights::pallet_transaction_payment::WeightInfo; } diff --git a/operator/runtime/testnet/tests/fee_adjustment.rs b/operator/runtime/testnet/tests/fee_adjustment.rs new file mode 100644 index 00000000..16eefe35 --- /dev/null +++ b/operator/runtime/testnet/tests/fee_adjustment.rs @@ -0,0 +1,227 @@ +// Copyright 2025 Moonbeam Foundation. +// This file is part of DataHaven. + +//! Fee adjustment integration tests for DataHaven testnet runtime +//! Based on Moonbeam's fee adjustment tests + +use datahaven_runtime_common::constants::gas::WEIGHT_PER_GAS; +use datahaven_testnet_runtime::{ + configs::{ + MinimumMultiplier, RuntimeBlockWeights, SlowAdjustingFeeUpdate, TargetBlockFullness, + TransactionPaymentAsGasPrice, + }, + currency::WEIGHT_FEE, + Runtime, System, +}; +use fp_evm::FeeCalculator; +use frame_support::pallet_prelude::DispatchClass; +use frame_support::traits::OnFinalize; +use sp_core::U256; +use sp_runtime::{traits::Convert, BuildStorage, FixedPointNumber, FixedU128, Perbill}; + +/// Helper function to run tests with a specific system weight +fn run_with_system_weight(w: frame_support::weights::Weight, mut assertions: F) +where + F: FnMut() -> (), +{ + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + System::set_block_consumed_resources(w, 0); + assertions() + }); +} + +#[test] +fn multiplier_can_grow_from_zero() { + let minimum_multiplier = MinimumMultiplier::get(); + let target = TargetBlockFullness::get() + * RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .max_total + .unwrap(); + // if the min is too small, then this will not change, and we are doomed forever. + // the weight is 1/100th bigger than target. + run_with_system_weight(target * 101 / 100, || { + let next = SlowAdjustingFeeUpdate::::convert(minimum_multiplier); + assert!( + next > minimum_multiplier, + "{:?} !>= {:?}", + next, + minimum_multiplier + ); + }) +} + +#[test] +fn fee_calculation() { + let base_extrinsic = RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .base_extrinsic; + let multiplier = FixedU128::from_float(0.999000000000000000); + let extrinsic_len = 100u32; + let extrinsic_weight = 5_000u64; + let tip = 42u128; + + // For IdentityFee, the fee is just the weight itself + // Formula: base_extrinsic + (multiplier * call_weight) + extrinsic_len + tip + let expected_fee = base_extrinsic.ref_time() as u128 + + (multiplier.saturating_mul_int(extrinsic_weight as u128)) + + extrinsic_len as u128 + + tip; + + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier); + let actual_fee = pallet_transaction_payment::Pallet::::compute_fee( + extrinsic_len, + &frame_support::dispatch::DispatchInfo { + class: DispatchClass::Normal, + pays_fee: frame_support::dispatch::Pays::Yes, + call_weight: frame_support::weights::Weight::from_parts(extrinsic_weight, 1), + extension_weight: frame_support::weights::Weight::zero(), + }, + tip, + ); + + assert_eq!( + expected_fee, actual_fee, + "The actual fee did not match the expected fee, expected: {}, actual: {}", + expected_fee, actual_fee + ); + }); +} + +#[test] +fn min_gas_price_is_deterministic() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let multiplier = FixedU128::from_u32(1); + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier); + let actual = TransactionPaymentAsGasPrice::min_gas_price().0; + let expected: U256 = multiplier + .saturating_mul_int(WEIGHT_FEE.saturating_mul(WEIGHT_PER_GAS as u128)) + .into(); + + assert_eq!(expected, actual); + }); +} + +#[test] +fn min_gas_price_has_no_precision_loss_from_saturating_mul_int() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let multiplier_1 = FixedU128::from_float(0.999593900000000000); + let multiplier_2 = FixedU128::from_float(0.999593200000000000); + + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier_1); + let a = TransactionPaymentAsGasPrice::min_gas_price(); + pallet_transaction_payment::NextFeeMultiplier::::set(multiplier_2); + let b = TransactionPaymentAsGasPrice::min_gas_price(); + + assert_ne!( + a, b, + "both gas prices were equal, unexpected precision loss incurred" + ); + }); +} + +#[test] +fn fee_scenarios() { + let mut t: sp_io::TestExternalities = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into(); + t.execute_with(|| { + let weight_fee_per_gas = WEIGHT_FEE.saturating_mul(WEIGHT_PER_GAS as u128); + let sim = |start_gas_price: u128, fullness: Perbill, num_blocks: u64| -> U256 { + let start_multiplier = FixedU128::from_rational(start_gas_price, weight_fee_per_gas); + pallet_transaction_payment::NextFeeMultiplier::::set(start_multiplier); + + let normal_weight = RuntimeBlockWeights::get() + .get(DispatchClass::Normal) + .max_total + .unwrap(); + let block_weight = normal_weight * fullness; + + for i in 0..num_blocks { + System::set_block_number(i as u32); + System::set_block_consumed_resources(block_weight, 0); + pallet_transaction_payment::Pallet::::on_finalize(i as u32); + } + + TransactionPaymentAsGasPrice::min_gas_price().0 + }; + + // The expected values are the ones observed during test execution, + // they are expected to change when parameters that influence + // the fee calculation are changed, and should be updated accordingly. + // If a test fails when nothing specific to fees has changed, + // it may indicate an unexpected collateral effect and should be investigated + + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 1), + U256::from(998_600_980), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 1), + U256::from(999_600_080), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 1), + U256::from(1_000_600_180), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 1), + U256::from(1_002_603_380), + ); + + // 1 "real" hour (at 6-second blocks) + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 600), + U256::from(431_710_642), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 600), + U256::from(786_627_866), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 600), + U256::from(1_433_329_383u128), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 600), + U256::from(4_758_812_897u128), + ); + + // 1 "real" day (at 6-second blocks) + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(0), 14400), + U256::from(312_500_000), // lower bound enforced + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(25), 14400), + U256::from(312_500_000), // lower bound enforced if threshold not reached + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(50), 14400), + U256::from(5_653_326_895_069u128), + ); + assert_eq!( + sim(1_000_000_000, Perbill::from_percent(100), 14400), + U256::from(31_250_000_000_000u128), + // upper bound enforced (min_gas_price * MaximumMultiplier) + ); + }); +} diff --git a/operator/runtime/testnet/tests/lib.rs b/operator/runtime/testnet/tests/lib.rs index 63cf45ef..3f26f028 100644 --- a/operator/runtime/testnet/tests/lib.rs +++ b/operator/runtime/testnet/tests/lib.rs @@ -1,6 +1,7 @@ //! Integration tests for DataHaven testnet runtime pub mod common; +mod fee_adjustment; pub mod governance; mod native_token_transfer; mod proxy;