From 2a03d9ff6fa8d7341ff4f977cbd060a1be003967 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Fri, 11 Sep 2026 20:13:54 -0500 Subject: [PATCH 1/8] test(platform): add a mock fee generation with doubled storage and a mock protocol version on the latest tables No shipped schedule carries a fee_version_number other than 1, so the fee registry, the epoch-change hook, the saved-state round trip and the history-driven refund path had no input that exercised a second generation. Add TEST_FEE_VERSION_DOUBLED_STORAGE (FEE_VERSION2 with a shifted generation number and a doubled storage disk usage rate) and TEST_PLATFORM_V4 (PLATFORM_V14 with that schedule) under mock-versions, register the mock in the test protocol registry, and resolve shifted fee numbers through the test registry in FeeVersion::get and get_optional. Production registries and every shipped PLATFORM_V* are unchanged. Co-Authored-By: Claude Fable 5.1 --- .../src/version/fee/mod.rs | 91 +++++++++++++++++++ .../version/mocks/fee_doubled_storage_test.rs | 47 ++++++++++ .../src/version/mocks/mod.rs | 2 + .../src/version/mocks/v4_test.rs | 24 +++++ .../src/version/protocol_version.rs | 9 +- .../src/version/system_limits/mod.rs | 8 +- 6 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 packages/rs-platform-version/src/version/mocks/fee_doubled_storage_test.rs create mode 100644 packages/rs-platform-version/src/version/mocks/v4_test.rs diff --git a/packages/rs-platform-version/src/version/fee/mod.rs b/packages/rs-platform-version/src/version/fee/mod.rs index 29e02b56012..9f941e70574 100644 --- a/packages/rs-platform-version/src/version/fee/mod.rs +++ b/packages/rs-platform-version/src/version/fee/mod.rs @@ -16,6 +16,10 @@ use crate::version::fee::v1::FEE_VERSION1; use crate::version::fee::vote_resolution_fund_fees::{ VoteResolutionFundFees, VoteResolutionFundFeesFieldsBeforeVersion4, }; +#[cfg(feature = "mock-versions")] +use crate::version::mocks::fee_doubled_storage_test::TEST_FEE_VERSIONS; +#[cfg(feature = "mock-versions")] +use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; use bincode::{Decode, Encode}; pub mod data_contract_registration; @@ -55,6 +59,22 @@ impl FeeVersion { } pub fn get<'a>(version: FeeVersionNumber) -> Result<&'a Self, PlatformVersionError> { if version > 0 { + #[cfg(feature = "mock-versions")] + { + // Test fee generations share the mock protocol versions' shifted + // number range, so a number with the test bit set is resolved by + // number in the test registry and never reaches the production one. + if version >> TEST_PROTOCOL_VERSION_SHIFT_BYTES > 0 { + return TEST_FEE_VERSIONS + .iter() + .find(|fee_version| fee_version.fee_version_number == version) + .ok_or_else(|| { + PlatformVersionError::UnknownVersionError(format!( + "no test fee version {version}" + )) + }); + } + } FEE_VERSIONS.get(version as usize - 1).ok_or_else(|| { PlatformVersionError::UnknownVersionError(format!("no fee version {version}")) }) @@ -67,6 +87,14 @@ impl FeeVersion { pub fn get_optional<'a>(version: FeeVersionNumber) -> Option<&'a Self> { if version > 0 { + #[cfg(feature = "mock-versions")] + { + if version >> TEST_PROTOCOL_VERSION_SHIFT_BYTES > 0 { + return TEST_FEE_VERSIONS + .iter() + .find(|fee_version| fee_version.fee_version_number == version); + } + } FEE_VERSIONS.get(version as usize - 1) } else { None @@ -149,3 +177,66 @@ impl From for FeeVersion { } } } + +#[cfg(all(test, feature = "mock-versions"))] +mod mock_fee_generation_tests { + use super::{FeeStorageVersion, FeeVersion, FEE_VERSIONS}; + use crate::version::mocks::fee_doubled_storage_test::{ + TEST_FEE_VERSION_DOUBLED_STORAGE, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE, + }; + use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; + use crate::version::PlatformVersion; + + #[test] + fn should_resolve_the_test_fee_generation_only_through_the_shifted_number_range() { + let resolved = FeeVersion::get(TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE) + .expect("the test fee generation resolves by its number"); + assert_eq!(resolved, &TEST_FEE_VERSION_DOUBLED_STORAGE); + assert_eq!( + FeeVersion::get_optional(TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE), + Some(&TEST_FEE_VERSION_DOUBLED_STORAGE) + ); + + let unregistered = (1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES) + 2; + assert!( + FeeVersion::get(unregistered).is_err(), + "a shifted number with no test generation is an error, not a fallback" + ); + assert!(FeeVersion::get_optional(unregistered).is_none()); + + assert!( + FEE_VERSIONS + .iter() + .all(|fee_version| fee_version.fee_version_number + >> TEST_PROTOCOL_VERSION_SHIFT_BYTES + == 0), + "the production registry must never carry a shifted number" + ); + } + + #[test] + fn should_keep_the_test_fee_generation_aligned_with_the_latest_schedule_except_storage_disk_usage( + ) { + let latest = &PlatformVersion::latest().fee_version; + let doubled = &TEST_FEE_VERSION_DOUBLED_STORAGE; + + assert_eq!( + doubled.storage.storage_disk_usage_credit_per_byte, + 2 * latest.storage.storage_disk_usage_credit_per_byte + ); + + // Everything but the generation number and the disk usage rate is the + // latest schedule, so a fee difference across the boundary is storage. + let aligned = FeeVersion { + fee_version_number: latest.fee_version_number, + storage: FeeStorageVersion { + storage_disk_usage_credit_per_byte: latest + .storage + .storage_disk_usage_credit_per_byte, + ..doubled.storage.clone() + }, + ..doubled.clone() + }; + assert_eq!(&aligned, latest); + } +} diff --git a/packages/rs-platform-version/src/version/mocks/fee_doubled_storage_test.rs b/packages/rs-platform-version/src/version/mocks/fee_doubled_storage_test.rs new file mode 100644 index 00000000000..c0ba5cafc37 --- /dev/null +++ b/packages/rs-platform-version/src/version/mocks/fee_doubled_storage_test.rs @@ -0,0 +1,47 @@ +use crate::version::fee::storage::v1::FEE_STORAGE_VERSION1; +use crate::version::fee::storage::FeeStorageVersion; +use crate::version::fee::v3::FEE_VERSION3; +use crate::version::fee::{FeeVersion, FeeVersionNumber}; +use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; + +/// The fee generation number of the doubled-storage test schedule. +/// +/// Test fee generations live in the same shifted range as the mock protocol +/// versions (a set high bit that no production number can carry), so a +/// persisted fee history that names this number is rejected by a node built +/// without `mock-versions`, exactly as a mock protocol version would be. +pub const TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE: FeeVersionNumber = + (1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES) + 1; + +/// Storage rates of the doubled-storage test schedule: the disk usage rate is +/// exactly twice `FEE_STORAGE_VERSION1`, every other storage rate (including +/// the TTL ephemeral rate) is unchanged. +pub const TEST_FEE_STORAGE_VERSION_DOUBLED: FeeStorageVersion = FeeStorageVersion { + storage_disk_usage_credit_per_byte: 2 * FEE_STORAGE_VERSION1.storage_disk_usage_credit_per_byte, + ..FEE_STORAGE_VERSION1 +}; + +/// A fee generation that exists only under `mock-versions`. +/// +/// No shipped schedule carries a `fee_version_number` other than 1, so the +/// registry lookup, the epoch-change hook, the saved-state round trip and the +/// history-driven refund path have no production input that exercises a +/// second generation. This schedule is `FEE_VERSION3`, the latest shipped +/// schedule, with a new generation number and a doubled storage disk usage +/// rate: processing, hashing, signature and vote resolution rates are +/// identical, so any fee difference across a boundary into this generation is +/// storage, and a refund priced at the wrong generation is off by a factor of +/// two. +/// +/// It is unreachable in production builds: the constant is compiled only +/// with `mock-versions`, and `FeeVersion::get` resolves the shifted number +/// range only under that feature. +pub const TEST_FEE_VERSION_DOUBLED_STORAGE: FeeVersion = FeeVersion { + fee_version_number: TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE, + storage: TEST_FEE_STORAGE_VERSION_DOUBLED, + ..FEE_VERSION3 +}; + +/// The test fee generations, resolved by number through `FeeVersion::get` +/// when the number is in the shifted test range. +pub const TEST_FEE_VERSIONS: &[FeeVersion] = &[TEST_FEE_VERSION_DOUBLED_STORAGE]; diff --git a/packages/rs-platform-version/src/version/mocks/mod.rs b/packages/rs-platform-version/src/version/mocks/mod.rs index aef47dea3d5..3c0a292247f 100644 --- a/packages/rs-platform-version/src/version/mocks/mod.rs +++ b/packages/rs-platform-version/src/version/mocks/mod.rs @@ -1,4 +1,6 @@ +pub mod fee_doubled_storage_test; pub mod v2_test; pub mod v3_test; +pub mod v4_test; pub const TEST_PROTOCOL_VERSION_SHIFT_BYTES: u32 = 28; diff --git a/packages/rs-platform-version/src/version/mocks/v4_test.rs b/packages/rs-platform-version/src/version/mocks/v4_test.rs new file mode 100644 index 00000000000..2eecbd000b4 --- /dev/null +++ b/packages/rs-platform-version/src/version/mocks/v4_test.rs @@ -0,0 +1,24 @@ +use crate::version::mocks::fee_doubled_storage_test::TEST_FEE_VERSION_DOUBLED_STORAGE; +use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; +use crate::version::protocol_version::PlatformVersion; +use crate::version::v14::PLATFORM_V14; + +pub const TEST_PROTOCOL_VERSION_4: u32 = (1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES) + 4; + +/// A mock protocol version that is the latest shipped protocol version with +/// the doubled-storage test fee generation. +/// +/// Every dispatch table is the shipped one, so an upgrade from the latest +/// protocol version to this mock changes nothing but the fee generation: +/// `perform_events_on_first_block_of_protocol_change` fires no migration +/// (its gates compare against shipped numbers, all below the mock), and the +/// only observable difference after activation is the storage rate and the +/// fee history entry the epoch-change hook records. +/// +/// When a new protocol version is introduced, move the base to its table so +/// the mock keeps tracking the latest shipped behaviour. +pub const TEST_PLATFORM_V4: PlatformVersion = PlatformVersion { + protocol_version: TEST_PROTOCOL_VERSION_4, + fee_version: TEST_FEE_VERSION_DOUBLED_STORAGE, + ..PLATFORM_V14 +}; diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index 00cc470bbc7..c61c4c16aec 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -8,6 +8,8 @@ use crate::version::mocks::v2_test::TEST_PLATFORM_V2; #[cfg(feature = "mock-versions")] use crate::version::mocks::v3_test::TEST_PLATFORM_V3; #[cfg(feature = "mock-versions")] +use crate::version::mocks::v4_test::TEST_PLATFORM_V4; +#[cfg(feature = "mock-versions")] use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; use crate::version::system_data_contract_versions::SystemDataContractVersions; #[cfg(feature = "mock-versions")] @@ -67,7 +69,8 @@ pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ // We use OnceLock to be able to modify the version mocks pub static PLATFORM_TEST_VERSIONS: OnceLock> = OnceLock::new(); #[cfg(feature = "mock-versions")] -const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = &[TEST_PLATFORM_V2, TEST_PLATFORM_V3]; +pub const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = + &[TEST_PLATFORM_V2, TEST_PLATFORM_V3, TEST_PLATFORM_V4]; pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V14; @@ -83,7 +86,7 @@ impl PlatformVersion { // Init default set of test versions let versions = PLATFORM_TEST_VERSIONS - .get_or_init(|| vec![TEST_PLATFORM_V2, TEST_PLATFORM_V3]); + .get_or_init(|| Vec::from(DEFAULT_PLATFORM_TEST_VERSIONS)); return versions.get(test_version as usize - 2).ok_or( PlatformVersionError::UnknownVersionError(format!( @@ -111,7 +114,7 @@ impl PlatformVersion { // Init default set of test versions let versions = PLATFORM_TEST_VERSIONS - .get_or_init(|| vec![TEST_PLATFORM_V2, TEST_PLATFORM_V3]); + .get_or_init(|| Vec::from(DEFAULT_PLATFORM_TEST_VERSIONS)); return versions.get(test_version as usize - 2); } diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 0af3e8e62bd..a1e779be3b0 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -313,12 +313,12 @@ mod tests { #[cfg(feature = "mock-versions")] #[test] fn mock_platform_versions_carry_the_same_documents_batch_cap() { - use crate::version::mocks::v2_test::TEST_PLATFORM_V2; - use crate::version::mocks::v3_test::TEST_PLATFORM_V3; - use crate::version::protocol_version::PLATFORM_TEST_VERSIONS; + use crate::version::protocol_version::{ + DEFAULT_PLATFORM_TEST_VERSIONS, PLATFORM_TEST_VERSIONS, + }; let versions = - PLATFORM_TEST_VERSIONS.get_or_init(|| vec![TEST_PLATFORM_V2, TEST_PLATFORM_V3]); + PLATFORM_TEST_VERSIONS.get_or_init(|| Vec::from(DEFAULT_PLATFORM_TEST_VERSIONS)); assert!( !versions.is_empty(), "the mock version registry is empty; this test would assert nothing" From a277f3f4b57cc29137c8d99c1812d1e6217f75a4 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Fri, 11 Sep 2026 20:14:30 -0500 Subject: [PATCH 2/8] fix(drive-abci): record the genesis fee generation in the initial platform state The epoch-change hook records a fee generation only on the first non-genesis epoch change, so a chain whose genesis schedule is not generation 1 would refund bytes stored in epoch 0 at generation 1 rates forever: the lookup falls back to the first registered generation below the earliest entry. Seed the genesis entry with the registry entry of the genesis schedule's number in PlatformState::default_with_protocol_versions. Unobservable on every existing network: their genesis generation is 1, which is exactly the fallback, and the fee history lives in the saved state, not in the app hash. Saved states created before the entry was recorded keep working through the same fallback branch of the hook. Also add platform-version-parameterised variants of the identity setup and state-transition processing test helpers so boundary tests can run under a mock version. Co-Authored-By: Claude Fable 5.1 --- .../upgrade_protocol_version/v0/mod.rs | 59 +++++++- .../state_transition/state_transitions/mod.rs | 28 +++- .../src/platform_types/platform_state/mod.rs | 141 +++++++++++++++++- 3 files changed, 223 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs index f87f67705b5..c9ca11aa2c6 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs @@ -107,7 +107,9 @@ impl Platform { &platform_version.fee_version, ); } - // In case of empty cached_fee_version, insert the new (epoch_index, fee_version) + // In case of empty cached_fee_version, insert the new (epoch_index, fee_version). + // A fresh state records its genesis generation at init chain, so this branch + // remains for saved states created before the genesis entry was recorded. } else { previous_fee_versions_map.insert( epoch_info.current_epoch_index(), @@ -177,6 +179,7 @@ mod tests { use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; + use dpp::fee::epoch::GENESIS_EPOCH_INDEX; use dpp::version::PlatformVersion; #[test] @@ -365,6 +368,60 @@ mod tests { ); } + #[test] + fn should_not_insert_a_second_entry_on_epoch_change_when_the_generation_is_unchanged() { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = platform.drive.grove.start_transaction(); + + let epoch_info = EpochInfo::V0(EpochInfoV0 { + current_epoch_index: 1, + previous_epoch_index: Some(0), + is_epoch_change: true, + }); + + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("expected epoch"), + }; + + let last_committed_state = platform.state.load(); + let mut block_platform_state = last_committed_state.as_ref().clone(); + + // The genesis generation is recorded at init chain, so the map is not empty. + let seeded = block_platform_state.previous_fee_versions().clone(); + assert_eq!(seeded.len(), 1, "a fresh state carries the genesis entry"); + assert_eq!( + seeded + .get(&GENESIS_EPOCH_INDEX) + .expect("genesis entry") + .fee_version_number, + platform_version.fee_version.fee_version_number + ); + + platform + .upgrade_protocol_version_on_epoch_change_v0( + &block_info, + &epoch_info, + &last_committed_state, + &mut block_platform_state, + &transaction, + platform_version, + ) + .expect("epoch change with the same protocol version succeeds"); + + assert_eq!( + block_platform_state.previous_fee_versions(), + &seeded, + "an epoch change within the same fee generation leaves the history alone" + ); + } + #[test] fn test_epoch_change_with_upgrade_vote_sets_next_version() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index c3ae91ed840..34955e30c57 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -214,12 +214,26 @@ pub(in crate::execution) mod tests { seed: u64, credits: Credits, ) -> (Identity, SimpleSigner, IdentityPublicKey) { - let platform_version = PlatformVersion::latest(); + setup_identity_with_system_credits_with_platform_version( + platform, + seed, + credits, + PlatformVersion::latest(), + ) + } + + /// Same as `setup_identity_with_system_credits`, under a chosen platform version + pub(in crate::execution) fn setup_identity_with_system_credits_with_platform_version( + platform: &mut TempPlatform, + seed: u64, + credits: Credits, + platform_version: &PlatformVersion, + ) -> (Identity, SimpleSigner, IdentityPublicKey) { platform .drive .add_to_system_credits(credits, None, platform_version) .expect("expected to add to system credits"); - setup_identity(platform, seed, credits) + setup_identity_with_platform_version(platform, seed, credits, platform_version) } pub(in crate::execution) fn setup_identity( @@ -227,7 +241,15 @@ pub(in crate::execution) mod tests { seed: u64, credits: Credits, ) -> (Identity, SimpleSigner, IdentityPublicKey) { - let platform_version = PlatformVersion::latest(); + setup_identity_with_platform_version(platform, seed, credits, PlatformVersion::latest()) + } + + pub(in crate::execution) fn setup_identity_with_platform_version( + platform: &mut TempPlatform, + seed: u64, + credits: Credits, + platform_version: &PlatformVersion, + ) -> (Identity, SimpleSigner, IdentityPublicKey) { let mut signer = SimpleSigner::default(); let mut rng = StdRng::seed_from_u64(seed); diff --git a/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs b/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs index f4467c9cce9..c68e02b286b 100644 --- a/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs @@ -33,7 +33,9 @@ use dpp::block::block_info::BlockInfo; use dpp::dashcore::hashes::Hash; use dpp::dashcore_rpc::json::MasternodeListItem; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::epoch::GENESIS_EPOCH_INDEX; use dpp::util::hash::hash_double; +use dpp::version::fee::FeeVersion; use std::collections::BTreeMap; use std::fmt::{Debug, Formatter}; @@ -192,6 +194,20 @@ impl PlatformState { ) -> Result { let platform_version = PlatformVersion::get(current_protocol_version_in_consensus)?; + // Record the genesis fee generation. The epoch-change hook only records a + // generation on the first non-genesis epoch change, so without this entry + // bytes stored in epoch 0 of a network whose genesis schedule is not + // generation 1 would be refunded at generation 1 rates forever (the lookup + // falls back to the first registered generation below the earliest entry). + // Unobservable on every existing network: their genesis generation is 1, + // which is exactly the fallback, and the map lives in the saved state, not + // in the app hash. Saved states created before this entry was recorded + // keep working through the same fallback. The value is the registry entry + // rather than the schedule reference so the in-memory map equals the map + // after a saved-state round trip, which resolves numbers through the + // registry. + let genesis_fee_version = FeeVersion::get(platform_version.fee_version.fee_version_number)?; + let state = PlatformState { last_committed_block_info: None, current_protocol_version_in_consensus, @@ -210,7 +226,10 @@ impl PlatformState { full_masternode_list: Default::default(), hpmn_masternode_list: Default::default(), genesis_block_info: None, - previous_fee_versions: Default::default(), + previous_fee_versions: CachedEpochIndexFeeVersions::from([( + GENESIS_EPOCH_INDEX, + genesis_fee_version, + )]), heavy_fields_dirty: true, masternode_changes: EntryChanges::all(), validator_set_changes: EntryChanges::all(), @@ -365,6 +384,126 @@ impl TryFromPlatformVersioned for PlatformState { mod tests { use super::*; + mod fee_history { + use super::*; + use crate::config::PlatformConfig; + use dpp::block::epoch::Epoch; + use dpp::fee::default_costs::{EpochCosts, KnownCostItem}; + use platform_version::version::mocks::fee_doubled_storage_test::{ + TEST_FEE_VERSION_DOUBLED_STORAGE, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE, + }; + use platform_version::version::mocks::v4_test::{ + TEST_PLATFORM_V4, TEST_PROTOCOL_VERSION_4, + }; + + const COST_ITEMS: [KnownCostItem; 5] = [ + KnownCostItem::StorageDiskUsageCreditPerByte, + KnownCostItem::StorageProcessingCreditPerByte, + KnownCostItem::StorageLoadCreditPerByte, + KnownCostItem::NonStorageLoadCreditPerByte, + KnownCostItem::StorageSeekCost, + ]; + + fn fresh_state(protocol_version: ProtocolVersion) -> PlatformState { + PlatformState::default_with_protocol_versions( + protocol_version, + protocol_version, + &PlatformConfig::default(), + ) + .expect("expected a default platform state") + } + + #[test] + fn should_record_the_genesis_fee_generation_in_a_fresh_state() { + let platform_version = PlatformVersion::latest(); + let state = fresh_state(platform_version.protocol_version); + + let expected = FeeVersion::get(platform_version.fee_version.fee_version_number) + .expect("the genesis schedule's number is registered"); + assert_eq!( + state.previous_fee_versions, + CachedEpochIndexFeeVersions::from([(GENESIS_EPOCH_INDEX, expected)]) + ); + + for epoch_index in [GENESIS_EPOCH_INDEX, 7] { + let epoch = Epoch::new(epoch_index).expect("epoch"); + assert_eq!( + epoch.active_fee_version(&state.previous_fee_versions), + expected, + "epoch {epoch_index} must resolve to the genesis generation" + ); + } + } + + #[test] + fn should_record_the_genesis_fee_generation_of_a_mock_version() { + let state = fresh_state(TEST_PROTOCOL_VERSION_4); + + assert_eq!( + state.previous_fee_versions, + CachedEpochIndexFeeVersions::from([( + GENESIS_EPOCH_INDEX, + &TEST_FEE_VERSION_DOUBLED_STORAGE + )]) + ); + let epoch = Epoch::new(GENESIS_EPOCH_INDEX).expect("epoch"); + assert_eq!( + epoch.cost_for_known_cost_item( + &state.previous_fee_versions, + KnownCostItem::StorageDiskUsageCreditPerByte + ), + 2 * PlatformVersion::latest() + .fee_version + .storage + .storage_disk_usage_credit_per_byte, + "genesis-epoch bytes of a chain started at the mock are priced at the doubled rate" + ); + } + + #[test] + fn should_round_trip_the_test_fee_generation_number_through_saved_state() { + let mut state = fresh_state(TEST_PROTOCOL_VERSION_4); + let first = &PlatformVersion::latest().fee_version; + state.previous_fee_versions = CachedEpochIndexFeeVersions::from([ + (GENESIS_EPOCH_INDEX, first.as_static()), + (3, &TEST_FEE_VERSION_DOUBLED_STORAGE), + ]); + + let bytes = state.serialize_to_bytes().expect("state serializes"); + let restored = PlatformState::versioned_deserialize_trusted(&bytes, &TEST_PLATFORM_V4) + .expect("state with a test fee generation number deserializes"); + + let numbers = |state: &PlatformState| { + state + .previous_fee_versions + .iter() + .map(|(epoch_index, fee_version)| { + (*epoch_index, fee_version.fee_version_number) + }) + .collect::>() + }; + assert_eq!(numbers(&restored), numbers(&state)); + assert_eq!( + numbers(&restored), + vec![ + (GENESIS_EPOCH_INDEX, first.fee_version_number), + (3, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE) + ] + ); + + for epoch_index in 0..=5 { + let epoch = Epoch::new(epoch_index).expect("epoch"); + for cost_item in COST_ITEMS { + assert_eq!( + epoch.cost_for_known_cost_item(&restored.previous_fee_versions, cost_item), + epoch.cost_for_known_cost_item(&state.previous_fee_versions, cost_item), + "epoch {epoch_index} costs must survive the saved-state round trip" + ); + } + } + } + } + mod versioned_deserialize { use super::*; use crate::test::fixture::platform_state::{ From 4ee8d392edcc7eb7ee0309714267a1e37bc73bf6 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:39:26 -0500 Subject: [PATCH 3/8] test(drive-abci): pin storage refunds on either side of a fee-generation boundary Drive the doubled-storage mock generation through the real block loop with the state's fee history: bytes stored after the generation activated refund at the doubled rate, bytes stored in the genesis epoch of a chain started at the mock refund at the genesis generation's rate (which only holds because the initial state records that generation), and a sectioned removal under a non-first generation is rejected without the fee history. Each run is compared against the same workload under the latest schedule, so a refund priced at the wrong generation is off by exactly a factor of two. Co-Authored-By: Claude Fable 5.1 --- .../block_processing_end_events/tests.rs | 749 ++++++++++++++++++ 1 file changed, 749 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs index 4ec54f0d698..3acbe47b4dd 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs @@ -1572,3 +1572,752 @@ mod storage_refund_clawback_tests { ); } } + +/// Storage refunds across a fee-generation boundary, driven through the real +/// block loop (`process_raw_state_transitions` with the state's fee history). +/// +/// The doubled-storage test generation (`TEST_PLATFORM_V4`) differs from the +/// latest shipped schedule only in its storage disk usage rate, so every +/// control run below is the same workload under `PlatformVersion::latest()`: +/// storage fees must double, processing fees must not move, and a refund +/// priced at the wrong generation is off by exactly a factor of two. +#[cfg(test)] +mod fee_generation_boundary { + use crate::execution::validation::state_transition::tests::{ + fetch_expected_identity_balance, process_state_transitions_with_platform_version, + setup_identity_with_system_credits_with_platform_version, + }; + use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::{Epoch, EpochIndex}; + use dpp::dash_to_credits; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use dpp::data_contract::document_type::DocumentTypeRef; + use dpp::data_contract::DataContract; + use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::fee::epoch::distribution::calculate_storage_fee_refund_amount_and_leftovers; + use dpp::fee::epoch::{CreditsPerEpoch, DEFAULT_EPOCHS_PER_ERA, GENESIS_EPOCH_INDEX}; + use dpp::fee::fee_result::FeeResult; + use dpp::fee::Credits; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::Bytes32; + use dpp::prelude::IdentityNonce; + use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::util::deserializer::ProtocolVersion; + use drive::drive::document::query::QueryDocumentsWithFlagsOutcomeV0Methods; + use drive::drive::Drive; + use drive::error::drive::DriveError; + use drive::error::Error as DriveCrateError; + use drive::fees::op::LowLevelDriveOperation; + use drive::grovedb_costs::storage_cost::removal::{ + StorageRemovalPerEpochByIdentifier, StorageRemovedBytes, + }; + use drive::grovedb_costs::storage_cost::StorageCost; + use drive::grovedb_costs::OperationCost; + use drive::query::DriveDocumentQuery; + use drive::util::storage_flags::StorageFlags; + use drive::util::test_helpers::setup_contract; + use platform_version::version::mocks::fee_doubled_storage_test::TEST_FEE_VERSION_DOUBLED_STORAGE; + use platform_version::version::mocks::v4_test::{TEST_PLATFORM_V4, TEST_PROTOCOL_VERSION_4}; + use platform_version::version::PlatformVersion; + use rand::prelude::StdRng; + use rand::SeedableRng; + use simple_signer::signer::SimpleSigner; + + const CONTRACT_PATH: &str = + "tests/supporting_files/contract/dashpay/dashpay-contract-no-indexes.json"; + const IDENTITY_SEED: u64 = 958; + const DOCUMENT_SEED: u64 = 433; + + /// Everything a boundary vector needs to compare one run against another. + struct Workload { + identity_balance_before: Credits, + /// The first document of the identity on the contract, which also pays + /// for joining the contract (the identity contract nonce). + join: FeeResult, + /// The document whose deletion is measured. + insertion: FeeResult, + deletion: FeeResult, + /// The deletion's refund to the identity, per storage epoch. + refunds: CreditsPerEpoch, + epochs_per_era: u16, + } + + fn assert_credits_balanced( + platform: &TempPlatform, + platform_version: &PlatformVersion, + ) { + let credits_verified = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("expected to check sum trees"); + + let balanced = credits_verified + .ok() + .expect("expected the credit sums to be checkable"); + + assert!(balanced, "platform should be balanced {}", credits_verified); + } + + /// The storage flags the stored document carries, read back from the tree + /// so a write that silently landed in the wrong epoch fails on the flags + /// rather than on the arithmetic. + fn stored_document_flags( + platform: &TempPlatform, + contract: &DataContract, + document_type: DocumentTypeRef<'_>, + document: &Document, + platform_version: &PlatformVersion, + ) -> StorageFlags { + let query = DriveDocumentQuery::new_primary_key_single_item_query( + contract, + document_type, + document.id(), + ); + let mut documents = platform + .drive + .query_documents_with_flags( + query, + None, + false, + None, + Some(platform_version.protocol_version), + ) + .expect("expected to query the stored document") + .documents_owned(); + let (_, storage_flags) = documents.pop().expect("expected the document to be stored"); + storage_flags.expect("expected the stored document to carry storage flags") + } + + fn assert_stored_in_epoch( + platform: &TempPlatform, + contract: &DataContract, + document_type: DocumentTypeRef<'_>, + document: &Document, + epoch_index: EpochIndex, + owner: &Identity, + platform_version: &PlatformVersion, + ) { + let storage_flags = stored_document_flags( + platform, + contract, + document_type, + document, + platform_version, + ); + assert_eq!( + *storage_flags.base_epoch(), + epoch_index, + "the document must be stored in epoch {epoch_index}, got {storage_flags:?}" + ); + assert_eq!( + storage_flags.owner_id(), + Some(&owner.id().to_buffer()), + "the document's bytes must be owned by the identity that pays for them" + ); + } + + #[allow(clippy::too_many_arguments)] + async fn store_document( + platform: &TempPlatform, + document_type: DocumentTypeRef<'_>, + rng: &mut StdRng, + identity: &Identity, + key: &IdentityPublicKey, + signer: &SimpleSigner, + identity_contract_nonce: IdentityNonce, + block_info: BlockInfo, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> (Document, FeeResult) { + let entropy = Bytes32::random_with_rng(rng); + + let mut document = document_type + .random_document_with_identifier_and_entropy( + rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + key, + identity_contract_nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let (mut fee_results, _) = process_state_transitions_with_platform_version( + platform, + &[documents_batch_create_transition], + block_info, + platform_state, + platform_version, + ); + + assert_credits_balanced(platform, platform_version); + + (document, fee_results.remove(0)) + } + + #[allow(clippy::too_many_arguments)] + async fn delete_document( + platform: &TempPlatform, + document: Document, + document_type: DocumentTypeRef<'_>, + key: &IdentityPublicKey, + signer: &SimpleSigner, + identity_contract_nonce: IdentityNonce, + block_info: BlockInfo, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> FeeResult { + let documents_batch_delete_transition = + BatchTransition::new_document_deletion_transition_from_document( + document, + document_type, + key, + identity_contract_nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let (mut fee_results, _) = process_state_transitions_with_platform_version( + platform, + &[documents_batch_delete_transition], + block_info, + platform_state, + platform_version, + ); + + assert_credits_balanced(platform, platform_version); + + fee_results.remove(0) + } + + /// The block state the hook would leave behind after the doubled-storage + /// generation activated at `activation_epoch`: the mock protocol version + /// is current, and the fee history carries the activation entry. + fn state_after_upgrade_to_doubled_storage( + platform: &TempPlatform, + activation_epoch: EpochIndex, + ) -> PlatformState { + let mut platform_state = platform.state.load().as_ref().clone(); + platform_state.set_current_protocol_version_in_consensus(TEST_PROTOCOL_VERSION_4); + platform_state.set_next_epoch_protocol_version(TEST_PROTOCOL_VERSION_4); + platform_state + .previous_fee_versions_mut() + .insert(activation_epoch, &TEST_FEE_VERSION_DOUBLED_STORAGE); + platform_state + } + + /// Recovers the number of bytes a refund paid for. + /// + /// GroveDB derives the removed byte count from the element it deletes and + /// the fee result only carries the priced refund, so the count is taken + /// from a refund known to be priced at `rate`: the refund grows by almost + /// a full rate per byte while the rounding drift of the era distribution + /// is bounded by the number of epochs, so exactly one count reproduces it. + fn removed_bytes_priced_at( + refund: Credits, + rate: Credits, + storage_epoch: EpochIndex, + current_epoch: EpochIndex, + epochs_per_era: u16, + max_bytes: u64, + ) -> u64 { + (1..=max_bytes) + .find(|bytes| { + expected_refund(bytes * rate, storage_epoch, current_epoch, epochs_per_era) + == refund + }) + .unwrap_or_else(|| { + panic!("no byte count up to {max_bytes} priced at {rate} refunds {refund} credits") + }) + } + + fn expected_refund( + storage_fee: Credits, + storage_epoch: EpochIndex, + current_epoch: EpochIndex, + epochs_per_era: u16, + ) -> Credits { + calculate_storage_fee_refund_amount_and_leftovers( + storage_fee, + storage_epoch, + current_epoch, + epochs_per_era, + ) + .expect("expected to compute the refund") + .0 + } + + fn refunds_for(fee_result: &FeeResult, identity: &Identity) -> CreditsPerEpoch { + fee_result + .fee_refunds + .get(&identity.id().to_buffer()) + .expect("expected refunds for the identity") + .clone() + } + + fn refund_amount(refunds: &CreditsPerEpoch, storage_epoch: EpochIndex) -> Credits { + assert_eq!( + refunds.len(), + 1, + "the refund must be sectioned into the single storage epoch {storage_epoch}, got {refunds:?}" + ); + *refunds + .get(&storage_epoch) + .expect("expected the refund to be keyed by the storage epoch") + } + + /// One run on either side of a boundary compared to the same run at the + /// latest schedule: storage doubles, processing does not move, and the + /// refund is exactly the refund of the same bytes at twice the rate. + fn assert_doubled_storage_generation( + doubled: &Workload, + control: &Workload, + storage_epoch: EpochIndex, + current_epoch: EpochIndex, + ) { + let control_rate = PlatformVersion::latest() + .fee_version + .storage + .storage_disk_usage_credit_per_byte; + let doubled_rate = TEST_FEE_VERSION_DOUBLED_STORAGE + .storage + .storage_disk_usage_credit_per_byte; + assert_eq!(doubled_rate, 2 * control_rate); + + for (doubled_fee, control_fee) in [ + (&doubled.join, &control.join), + (&doubled.insertion, &control.insertion), + ] { + assert_eq!( + doubled_fee.storage_fee, + 2 * control_fee.storage_fee, + "bytes stored under the doubled generation cost twice the latest rate" + ); + assert_eq!( + doubled_fee.processing_fee, control_fee.processing_fee, + "only the storage rate differs between the generations" + ); + } + assert_eq!(doubled.deletion.storage_fee, 0); + assert_eq!(control.deletion.storage_fee, 0); + assert_eq!(doubled.epochs_per_era, control.epochs_per_era); + + let control_refund = refund_amount(&control.refunds, storage_epoch); + let doubled_refund = refund_amount(&doubled.refunds, storage_epoch); + + let removed_bytes = removed_bytes_priced_at( + control_refund, + control_rate, + storage_epoch, + current_epoch, + control.epochs_per_era, + control.insertion.storage_fee / control_rate, + ); + assert_eq!( + doubled_refund, + expected_refund( + removed_bytes * doubled_rate, + storage_epoch, + current_epoch, + doubled.epochs_per_era + ), + "the refund of {removed_bytes} bytes stored in epoch {storage_epoch} and removed in \ + epoch {current_epoch} must be priced at the doubled generation" + ); + } + + /// Bytes stored in epoch 2 and removed in epoch 3, optionally after the + /// doubled-storage generation activated in epoch 2. + async fn store_after_boundary_delete_next_epoch(activation: Option) -> Workload { + const STORAGE_EPOCH: EpochIndex = 2; + const REMOVAL_EPOCH: EpochIndex = 3; + + let latest = PlatformVersion::latest(); + let processing_version = if activation.is_some() { + PlatformVersion::get(TEST_PROTOCOL_VERSION_4).expect("expected the mock version") + } else { + latest + }; + + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let contract = setup_contract( + &platform.drive, + CONTRACT_PATH, + None, + None, + None::, + None, + None, + ); + let profile = contract + .document_type_for_name("profile") + .expect("expected a profile document type"); + + let mut rng = StdRng::seed_from_u64(DOCUMENT_SEED); + + let (identity, signer, key) = setup_identity_with_system_credits_with_platform_version( + &mut platform, + IDENTITY_SEED, + dash_to_credits!(1), + latest, + ); + let identity_balance_before = dash_to_credits!(1); + + fast_forward_to_block(&platform, 1_200_000_000, 900, 42, STORAGE_EPOCH, false); + let block_state = |platform: &TempPlatform| match activation { + Some(activation_epoch) => { + state_after_upgrade_to_doubled_storage(platform, activation_epoch) + } + None => platform.state.load().as_ref().clone(), + }; + let platform_state = block_state(&platform); + let block_info = *platform_state.last_block_info(); + assert_eq!(block_info.epoch.index, STORAGE_EPOCH); + + let (join_document, join) = store_document( + &platform, + profile, + &mut rng, + &identity, + &key, + &signer, + 1, + block_info, + &platform_state, + processing_version, + ) + .await; + let (document, insertion) = store_document( + &platform, + profile, + &mut rng, + &identity, + &key, + &signer, + 2, + block_info, + &platform_state, + processing_version, + ) + .await; + for stored in [&join_document, &document] { + assert_stored_in_epoch( + &platform, + &contract, + profile, + stored, + STORAGE_EPOCH, + &identity, + processing_version, + ); + } + + fast_forward_to_block(&platform, 1_800_000_000, 1_300, 42, REMOVAL_EPOCH, false); + let platform_state = block_state(&platform); + let block_info = *platform_state.last_block_info(); + assert_eq!(block_info.epoch.index, REMOVAL_EPOCH); + + let deletion = delete_document( + &platform, + document, + profile, + &key, + &signer, + 3, + block_info, + &platform_state, + processing_version, + ) + .await; + let refunds = refunds_for(&deletion, &identity); + + fetch_expected_identity_balance( + &platform, + identity.id(), + processing_version, + identity_balance_before + - join.total_base_fee() + - insertion.total_base_fee() + - deletion.total_base_fee() + + refunds.values().sum::(), + ); + + Workload { + identity_balance_before, + join, + insertion, + deletion, + refunds, + epochs_per_era: platform.config.drive.epochs_per_era, + } + } + + /// Bytes stored in the genesis epoch of a chain started at + /// `protocol_version` and removed in epoch 1, with the fee history the + /// initial state records. + async fn store_at_genesis_delete_in_epoch_one(protocol_version: ProtocolVersion) -> Workload { + const REMOVAL_EPOCH: EpochIndex = 1; + + let platform_version = + PlatformVersion::get(protocol_version).expect("expected the protocol version"); + + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let contract = setup_contract( + &platform.drive, + CONTRACT_PATH, + None, + None, + None::, + None, + Some(platform_version), + ); + let profile = contract + .document_type_for_name("profile") + .expect("expected a profile document type"); + + let mut rng = StdRng::seed_from_u64(DOCUMENT_SEED); + + let (identity, signer, key) = setup_identity_with_system_credits_with_platform_version( + &mut platform, + IDENTITY_SEED, + dash_to_credits!(1), + platform_version, + ); + let identity_balance_before = dash_to_credits!(1); + + let platform_state = platform.state.load().as_ref().clone(); + assert_eq!( + platform_state.previous_fee_versions(), + &CachedEpochIndexFeeVersions::from([( + GENESIS_EPOCH_INDEX, + platform_version.fee_version.as_static() + )]), + "the initial state records the genesis fee generation" + ); + let block_info = BlockInfo::default(); + assert_eq!(block_info.epoch.index, GENESIS_EPOCH_INDEX); + + let (join_document, join) = store_document( + &platform, + profile, + &mut rng, + &identity, + &key, + &signer, + 1, + block_info, + &platform_state, + platform_version, + ) + .await; + let (document, insertion) = store_document( + &platform, + profile, + &mut rng, + &identity, + &key, + &signer, + 2, + block_info, + &platform_state, + platform_version, + ) + .await; + for stored in [&join_document, &document] { + assert_stored_in_epoch( + &platform, + &contract, + profile, + stored, + GENESIS_EPOCH_INDEX, + &identity, + platform_version, + ); + } + + fast_forward_to_block(&platform, 1_200_000_000, 900, 42, REMOVAL_EPOCH, false); + let platform_state = platform.state.load().as_ref().clone(); + let block_info = *platform_state.last_block_info(); + assert_eq!(block_info.epoch.index, REMOVAL_EPOCH); + + let deletion = delete_document( + &platform, + document, + profile, + &key, + &signer, + 3, + block_info, + &platform_state, + platform_version, + ) + .await; + let refunds = refunds_for(&deletion, &identity); + + fetch_expected_identity_balance( + &platform, + identity.id(), + platform_version, + identity_balance_before + - join.total_base_fee() + - insertion.total_base_fee() + - deletion.total_base_fee() + + refunds.values().sum::(), + ); + + Workload { + identity_balance_before, + join, + insertion, + deletion, + refunds, + epochs_per_era: platform.config.drive.epochs_per_era, + } + } + + #[tokio::test] + async fn should_refund_bytes_stored_after_a_fee_generation_boundary_at_the_new_rate() { + let control = store_after_boundary_delete_next_epoch(None).await; + let doubled = store_after_boundary_delete_next_epoch(Some(2)).await; + + assert_eq!( + doubled.identity_balance_before, + control.identity_balance_before + ); + assert_doubled_storage_generation(&doubled, &control, 2, 3); + } + + #[tokio::test] + async fn should_refund_genesis_epoch_bytes_at_the_genesis_fee_generation_rate() { + // Without the genesis entry in the fee history the lookup would fall + // back to the first registered generation and refund at half the rate + // the bytes were paid for. + let control = + store_at_genesis_delete_in_epoch_one(PlatformVersion::latest().protocol_version).await; + let doubled = store_at_genesis_delete_in_epoch_one(TEST_PROTOCOL_VERSION_4).await; + + assert_eq!( + doubled.identity_balance_before, + control.identity_balance_before + ); + assert_doubled_storage_generation(&doubled, &control, GENESIS_EPOCH_INDEX, 1); + } + + #[test] + fn should_reject_a_non_first_fee_generation_removal_without_fee_history() { + const REMOVED_BYTES: u32 = 1_000; + let owner = [7u8; 32]; + let epoch = Epoch::new(3).expect("expected an epoch"); + + let removal = || { + let mut removal_per_epoch_by_identifier = StorageRemovalPerEpochByIdentifier::new(); + removal_per_epoch_by_identifier + .entry(owner) + .or_default() + .insert(GENESIS_EPOCH_INDEX, REMOVED_BYTES); + LowLevelDriveOperation::CalculatedCostOperation(OperationCost { + storage_cost: StorageCost { + added_bytes: 0, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::SectionedStorageRemoval( + removal_per_epoch_by_identifier, + ), + }, + ..Default::default() + }) + }; + + // A generation other than the first cannot price a sectioned removal + // without the fee history: the lifecycle callers that pass `None` may + // only remove unflagged bytes. + let without_history = Drive::calculate_fee( + None, + Some(vec![removal()]), + &epoch, + DEFAULT_EPOCHS_PER_ERA, + &TEST_PLATFORM_V4, + None, + ); + assert!( + matches!( + without_history, + Err(DriveCrateError::Drive(DriveError::CorruptedCodeExecution( + _ + ))) + ), + "expected a corrupted code execution error, got {without_history:?}" + ); + + // With the history the same removal is refunded at the generation + // active for the storage epoch's lookup. + let history = CachedEpochIndexFeeVersions::from([( + GENESIS_EPOCH_INDEX, + &TEST_FEE_VERSION_DOUBLED_STORAGE, + )]); + let fee_result = Drive::calculate_fee( + None, + Some(vec![removal()]), + &epoch, + DEFAULT_EPOCHS_PER_ERA, + &TEST_PLATFORM_V4, + Some(&history), + ) + .expect("expected the removal to be priced with the fee history"); + assert_eq!( + fee_result + .fee_refunds + .get(&owner) + .and_then(|refunds| refunds.get(&GENESIS_EPOCH_INDEX)) + .copied(), + Some(expected_refund( + Credits::from(REMOVED_BYTES) + * TEST_FEE_VERSION_DOUBLED_STORAGE + .storage + .storage_disk_usage_credit_per_byte, + GENESIS_EPOCH_INDEX, + epoch.index, + DEFAULT_EPOCHS_PER_ERA, + )) + ); + } +} From b5587d76cca8ebb03f082b54b2a38e5ea861a475 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:44:10 -0500 Subject: [PATCH 4/8] test(strategy-tests): replay a chain across a fee-generation boundary against its saved state Two chain simulations cross into the doubled-storage mock generation: an upgrade from the latest protocol version that activates at epoch 2, and a chain started at the mock. Each runs one workload twice, once continuously and once stopped at the same block, reopened from the persisted state and continued with the mutated strategy, identities, signer and nonce counters of the first segment, so both segments execute the same transitions. The runs must agree on every root hash, transition result, identity balance and the fee history, and every block re-runs process_proposal as an independent validator. The harness outcome now returns the transitions the strategy submitted per block, so a test can prove two runs executed the same workload. Co-Authored-By: Claude Fable 5.1 --- .../tests/strategy_tests/execution.rs | 1 + .../tests/strategy_tests/strategy.rs | 3 + .../test_cases/fee_version_boundary_tests.rs | 615 ++++++++++++++++++ .../tests/strategy_tests/test_cases/mod.rs | 1 + 4 files changed, 620 insertions(+) create mode 100644 packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs diff --git a/packages/rs-drive-abci/tests/strategy_tests/execution.rs b/packages/rs-drive-abci/tests/strategy_tests/execution.rs index 6d25d111b30..d359cafada5 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/execution.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/execution.rs @@ -1249,6 +1249,7 @@ pub(crate) async fn continue_chain_for_strategy<'a>( withdrawals: total_withdrawals, validator_set_updates, state_transition_results_per_block, + state_transitions_per_block, instant_lock_quorums, signer, } diff --git a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs index 96820a70270..8035d72c3f3 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs @@ -3118,6 +3118,9 @@ pub struct ChainExecutionOutcome<'a> { /// height to the validator set update at that height pub validator_set_updates: BTreeMap, pub state_transition_results_per_block: BTreeMap>, + /// Every state transition the strategy submitted, per block, whether or not + /// the proposer kept it; lets a test prove two runs executed one workload. + pub state_transitions_per_block: BTreeMap>, pub signer: SimpleSigner, } diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs new file mode 100644 index 00000000000..8428770cd45 --- /dev/null +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs @@ -0,0 +1,615 @@ +//! Replay coverage across a fee-generation boundary. +//! +//! The doubled-storage mock protocol version (`TEST_PLATFORM_V4`) is the +//! latest shipped tables with a fee generation that exists only under +//! `mock-versions`, so upgrading to it changes nothing but the storage rate +//! and the fee history entry the epoch-change hook records. Each simulation +//! runs one workload twice: continuously, and stopped at the same block, +//! reopened from the persisted state and continued. The two runs must agree +//! on every root hash, every transition result, every identity balance and +//! the fee history; every block also re-runs `process_proposal` as an +//! independent validator, which is the proposer versus validator parity. + +#[cfg(test)] +mod tests { + use crate::addresses_with_balance::AddressesWithBalance; + use crate::execution::{continue_chain_for_strategy, run_chain_for_strategy}; + use crate::strategy::{ + ChainExecutionOutcome, ChainExecutionParameters, NetworkStrategy, StrategyRandomness, + UpgradingInfo, + }; + use dash_platform_macros::stack_size; + use dpp::block::epoch::EpochIndex; + use dpp::dash_to_duffs; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::random_document::{ + DocumentFieldFillSize, DocumentFieldFillType, + }; + use dpp::fee::Credits; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::platform_value::Identifier; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use dpp::state_transition::batch_transition::batched_transition::document_transition::{ + DocumentTransition, DocumentTransitionV0Methods, + }; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; + use dpp::state_transition::StateTransition; + use dpp::tests::json_document::json_document_to_created_contract; + use dpp::version::fee::FeeVersionNumber; + use dpp::version::PlatformVersion; + use drive_abci::abci::app::FullAbciApplication; + use drive_abci::config::{ + ChainLockConfig, ExecutionConfig, InstantLockConfig, PlatformConfig, PlatformTestConfig, + ValidatorSetConfig, + }; + use drive_abci::platform_types::platform_state::PlatformStateV0Methods; + use drive_abci::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use platform_version::version::mocks::fee_doubled_storage_test::TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE; + use platform_version::version::mocks::v4_test::TEST_PROTOCOL_VERSION_4; + use std::collections::{BTreeMap, BTreeSet}; + use strategy_tests::frequency::Frequency; + use strategy_tests::operations::{DocumentAction, DocumentOp, Operation, OperationType}; + use strategy_tests::{IdentityInsertInfo, StartAddresses, StartIdentities, Strategy}; + use tenderdash_abci::proto::abci::ExecTxResult; + + /// Sixty blocks per epoch: `epoch_time_length_s` of 60 at one block per second. + const BLOCKS_PER_EPOCH: u64 = 60; + const SEED: u64 = 41; + + /// What a run leaves behind, in the fields the two runs must agree on. + #[derive(Debug, PartialEq, Eq)] + struct Snapshot { + root_hash: [u8; 32], + height: u64, + protocol_version: u32, + /// Per block: the code and gas of every transition the proposer kept. + results_per_block: BTreeMap>, + /// Per block of the second segment: the bytes of every transition the + /// strategy submitted, which proves the two runs executed one workload. + submitted_per_block: BTreeMap>>, + balances: BTreeMap<[u8; 32], Credits>, + fee_history: Vec<(EpochIndex, FeeVersionNumber)>, + } + + /// Document ids created or deleted by the kept transitions of a run, + /// keyed by block. + #[derive(Default)] + struct DocumentActivity { + created: BTreeMap>, + deleted: BTreeMap>, + } + + fn document_activity( + results_per_block: &BTreeMap>, + ) -> DocumentActivity { + let mut activity = DocumentActivity::default(); + for (height, results) in results_per_block { + for (state_transition, result) in results { + let StateTransition::Batch(batch) = state_transition else { + continue; + }; + if result.code != 0 { + continue; + } + for transition in batch.transitions_iter() { + let BatchedTransitionRef::Document(document_transition) = transition else { + continue; + }; + let bucket = match document_transition { + DocumentTransition::Create(_) => &mut activity.created, + DocumentTransition::Delete(_) => &mut activity.deleted, + _ => continue, + }; + bucket + .entry(*height) + .or_default() + .insert(document_transition.get_id()); + } + } + } + activity + } + + fn platform_config() -> PlatformConfig { + PlatformConfig { + validator_set: ValidatorSetConfig { + quorum_size: 30, + ..Default::default() + }, + chain_lock: ChainLockConfig::default_100_67(), + instant_lock: InstantLockConfig::default_100_67(), + execution: ExecutionConfig { + verify_sum_trees: true, + epoch_time_length_s: BLOCKS_PER_EPOCH, + ..Default::default() + }, + block_spacing_ms: 1_000, + testing_configs: PlatformTestConfig { + store_platform_state: true, + ..PlatformTestConfig::default_minimal_verifications() + }, + ..Default::default() + } + } + + /// One identity per block, one to two random inserts and one delete per + /// block on the all-mutable DashPay contract, so documents are written and + /// removed on both sides of every boundary the tests cross. + fn network_strategy(upgrading_info: Option) -> NetworkStrategy { + let platform_version = PlatformVersion::latest(); + let created_contract = json_document_to_created_contract( + "tests/supporting_files/contract/dashpay/dashpay-contract-all-mutable.json", + 1, + true, + platform_version, + ) + .expect("expected to get contract from a json document"); + let contract = created_contract.data_contract(); + let document_type = contract + .document_type_for_name("contactRequest") + .expect("expected a contactRequest document type") + .to_owned_document_type(); + + NetworkStrategy { + strategy: Strategy { + start_contracts: vec![(created_contract.clone(), None)], + operations: vec![ + Operation { + op_type: OperationType::Document(DocumentOp { + contract: contract.clone(), + action: DocumentAction::DocumentActionInsertRandom( + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + ), + document_type: document_type.clone(), + }), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }, + Operation { + op_type: OperationType::Document(DocumentOp { + contract: contract.clone(), + action: DocumentAction::DocumentActionDelete, + document_type, + }), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }, + ], + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo { + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + start_balance_range: dash_to_duffs!(1)..=dash_to_duffs!(1), + ..Default::default() + }, + identity_contract_nonce_gaps: None, + signer: None, + }, + total_hpmns: 50, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 24, + upgrading_info, + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: true, + independent_process_proposal_verification: true, + ..Default::default() + } + } + + fn snapshot( + abci_app: &FullAbciApplication<'_, drive_abci::rpc::core::MockCoreRPCLike>, + identities: &[dpp::identity::Identity], + results_per_block: &BTreeMap>, + submitted_per_block: &BTreeMap>, + second_segment_start: u64, + ) -> Snapshot { + let state = abci_app.platform.state.load(); + let platform_version = state + .current_platform_version() + .expect("expected the state's platform version"); + let balances = abci_app + .platform + .drive + .fetch_identities_balances( + &identities + .iter() + .map(|identity| identity.id().to_buffer()) + .collect(), + None, + platform_version, + ) + .expect("expected to fetch identity balances"); + assert_eq!( + balances.len(), + identities.len(), + "every identity the strategy created must have a balance" + ); + Snapshot { + root_hash: state + .last_committed_block_app_hash() + .expect("expected a committed block"), + height: state.last_committed_block_height(), + protocol_version: state.current_protocol_version_in_consensus(), + results_per_block: results_per_block + .iter() + .map(|(height, results)| { + ( + *height, + results + .iter() + .map(|(_, result)| (result.code, result.gas_used)) + .collect(), + ) + }) + .collect(), + submitted_per_block: submitted_per_block + .iter() + .filter(|(height, _)| **height >= second_segment_start) + .map(|(height, transitions)| { + ( + *height, + transitions + .iter() + .map(|transition| { + transition + .serialize_to_bytes() + .expect("expected to serialize the transition") + }) + .collect(), + ) + }) + .collect(), + balances, + fee_history: state + .previous_fee_versions() + .iter() + .map(|(epoch_index, fee_version)| (*epoch_index, fee_version.fee_version_number)) + .collect(), + } + } + + struct SplitRun { + snapshot: Snapshot, + activity: DocumentActivity, + /// Fee history as the first segment left it. + fee_history_at_split: Vec<(EpochIndex, FeeVersionNumber)>, + protocol_version_at_split: u32, + } + + /// Runs `first_blocks` blocks, then `second_blocks` more with the same + /// workload. With `reopen` the platform is dropped between the segments + /// and rebuilt from what it persisted, so the second segment runs on the + /// saved state instead of the in-memory one. + /// + /// The continuation is workload-preserving: the mutated strategy of the + /// first segment (its start contracts already deployed and its operations + /// remapped to the deployed contract), its identities, its signer and its + /// nonce counters are handed to the second segment, and both runs reseed + /// the second segment from the same entropy. + async fn run_split( + mut platform: TempPlatform, + config: PlatformConfig, + strategy: NetworkStrategy, + first_blocks: u64, + second_blocks: u64, + reopen: bool, + ) -> SplitRun { + let ChainExecutionOutcome { + abci_app, + proposers, + validator_quorums, + current_validator_quorum_hash, + current_proposer_versions, + end_time_ms, + identity_nonce_counter, + identity_contract_nonce_counter, + instant_lock_quorums, + identities, + signer, + strategy: mut continued_strategy, + state_transition_results_per_block: first_results, + state_transitions_per_block: first_submitted, + .. + } = run_chain_for_strategy( + &mut platform, + first_blocks, + strategy.clone(), + config.clone(), + SEED, + &mut None, + &mut None, + ) + .await; + + let state = abci_app.platform.state.load(); + assert_eq!(state.last_committed_block_height(), first_blocks); + let fee_history_at_split = state + .previous_fee_versions() + .iter() + .map(|(epoch_index, fee_version)| (*epoch_index, fee_version.fee_version_number)) + .collect::>(); + let protocol_version_at_split = state.current_protocol_version_in_consensus(); + drop(state); + drop(abci_app); + + assert!( + continued_strategy.strategy.start_contracts.is_empty(), + "the first segment deploys the start contracts; the continuation must not redeploy them" + ); + continued_strategy.strategy.signer = Some(signer); + + let platform = if reopen { + let TempPlatform { + platform: mut platform_before_restart, + tempdir, + } = platform; + let core_rpc = std::mem::take(&mut platform_before_restart.core_rpc); + drop(platform_before_restart); + let mut reopened = TempPlatform::open_with_tempdir(tempdir, config.clone()); + reopened.platform.core_rpc = core_rpc; + let state = reopened.state.load(); + assert_eq!( + state.last_committed_block_height(), + first_blocks, + "the reopened platform must resume from the persisted state" + ); + assert_eq!( + state + .previous_fee_versions() + .iter() + .map(|(epoch_index, fee_version)| ( + *epoch_index, + fee_version.fee_version_number + )) + .collect::>(), + fee_history_at_split, + "the fee history must survive the saved-state round trip" + ); + assert_eq!( + state.current_protocol_version_in_consensus(), + protocol_version_at_split + ); + drop(state); + reopened + } else { + platform + }; + let abci_app = FullAbciApplication::new(&platform.platform); + + let ChainExecutionOutcome { + abci_app, + identities, + state_transition_results_per_block: second_results, + state_transitions_per_block: second_submitted, + .. + } = continue_chain_for_strategy( + abci_app, + ChainExecutionParameters { + block_start: first_blocks + 1, + core_height_start: 1, + block_count: second_blocks, + proposers, + validator_quorums, + current_validator_quorum_hash, + current_proposer_versions: Some(current_proposer_versions), + current_identity_nonce_counter: identity_nonce_counter, + current_identity_contract_nonce_counter: identity_contract_nonce_counter, + current_votes: BTreeMap::default(), + start_time_ms: strategy.start_time_ms, + current_time_ms: end_time_ms, + instant_lock_quorums, + current_identities: identities, + current_addresses_with_balance: AddressesWithBalance::default(), + }, + continued_strategy, + config, + StrategyRandomness::SeedEntropy(SEED + 1), + ) + .await; + + let mut results_per_block = first_results; + results_per_block.extend(second_results); + let mut submitted_per_block = first_submitted; + submitted_per_block.extend(second_submitted); + + let snapshot = snapshot( + &abci_app, + &identities, + &results_per_block, + &submitted_per_block, + first_blocks + 1, + ); + assert_eq!(snapshot.height, first_blocks + second_blocks); + let activity = document_activity(&results_per_block); + + SplitRun { + snapshot, + activity, + fee_history_at_split, + protocol_version_at_split, + } + } + + fn assert_no_internal_errors(snapshot: &Snapshot) { + const INTERNAL_ERROR_CODE: u32 = 13; + for (height, results) in &snapshot.results_per_block { + assert!( + results.iter().all(|(code, _)| *code != INTERNAL_ERROR_CODE), + "block {height} produced an internal error: {results:?}" + ); + } + } + + /// At least one document written before `split` was deleted at or after + /// `from`, so the history-driven refund path priced bytes that were + /// persisted before the restart. + fn assert_deleted_earlier_document(activity: &DocumentActivity, split: u64, from: u64) { + let created_before_split = activity + .created + .range(..=split) + .flat_map(|(_, ids)| ids.iter().copied()) + .collect::>(); + let deleted_after = activity + .deleted + .range(from..) + .flat_map(|(_, ids)| ids.iter().copied()) + .collect::>(); + assert!( + !created_before_split.is_empty(), + "expected documents to be created before block {split}" + ); + assert!( + deleted_after + .iter() + .any(|id| created_before_split.contains(id)), + "expected a document created before block {split} to be deleted from block {from}; \ + deleted {deleted_after:?}" + ); + } + + fn assert_runs_agree(continuous: &SplitRun, reopened: &SplitRun) { + assert_eq!( + continuous.snapshot.submitted_per_block, reopened.snapshot.submitted_per_block, + "both runs must submit the same transitions after the split" + ); + assert_eq!( + continuous.snapshot.results_per_block, reopened.snapshot.results_per_block, + "both runs must accept the same transitions with the same fees" + ); + assert_eq!(continuous.snapshot.balances, reopened.snapshot.balances); + assert_eq!( + continuous.snapshot.fee_history, + reopened.snapshot.fee_history + ); + assert_eq!( + continuous.snapshot.root_hash, reopened.snapshot.root_hash, + "the continuous run and the run reopened from saved state must end on one root hash" + ); + assert_eq!(continuous.snapshot, reopened.snapshot); + assert_no_internal_errors(&continuous.snapshot); + } + + /// Every proposer votes for the mock from block 1, so epoch 0 collects + /// the votes, the first block of epoch 1 locks the mock in as the next + /// version, and the first block of epoch 2 activates it and records the + /// doubled-storage generation at epoch 2. The split at block 130 sits + /// nine blocks after activation, inside the new generation. + #[stack_size(4 * 1024 * 1024)] + #[test] + async fn run_chain_upgrade_across_fee_version_boundary_keeps_replay_and_saved_state_in_agreement( + ) { + let latest = PlatformVersion::latest(); + let genesis_number = latest.fee_version.fee_version_number; + let activation_block = 2 * BLOCKS_PER_EPOCH + 1; + let first_blocks = 130; + let second_blocks = 30; + assert!(first_blocks > activation_block); + + let strategy = network_strategy(Some(UpgradingInfo { + current_protocol_version: latest.protocol_version, + proposed_protocol_versions_with_weight: vec![(TEST_PROTOCOL_VERSION_4, 1)], + upgrade_three_quarters_life: 0.0, + })); + let config = platform_config(); + + let mut runs = Vec::with_capacity(2); + for reopen in [false, true] { + let platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .with_initial_protocol_version(latest.protocol_version) + .build_with_mock_rpc(); + let run = run_split( + platform, + config.clone(), + strategy.clone(), + first_blocks, + second_blocks, + reopen, + ) + .await; + + assert_eq!(run.protocol_version_at_split, TEST_PROTOCOL_VERSION_4); + assert_eq!(run.snapshot.protocol_version, TEST_PROTOCOL_VERSION_4); + assert_eq!( + run.fee_history_at_split, + vec![ + (0, genesis_number), + (2, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE) + ], + "the genesis generation is recorded at init chain and the doubled generation at \ + the epoch that activated it" + ); + assert_eq!(run.snapshot.fee_history, run.fee_history_at_split); + assert_deleted_earlier_document(&run.activity, first_blocks, activation_block); + assert_deleted_earlier_document(&run.activity, first_blocks, first_blocks + 1); + runs.push(run); + } + + let reopened = runs.pop().expect("reopened run"); + let continuous = runs.pop().expect("continuous run"); + assert_runs_agree(&continuous, &reopened); + } + + /// A chain started at the mock: the initial state records the doubled + /// generation for the genesis epoch, and the epoch changes at blocks 61 + /// and 121 (the second one after the restart) leave the history alone + /// because the generation does not change. + #[stack_size(4 * 1024 * 1024)] + #[test] + async fn run_chain_started_at_a_new_fee_generation_records_the_genesis_generation_and_survives_a_restart( + ) { + let first_blocks = 70; + let second_blocks = 60; + + let strategy = network_strategy(None); + let config = platform_config(); + + let mut runs = Vec::with_capacity(2); + for reopen in [false, true] { + let platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .with_initial_protocol_version(TEST_PROTOCOL_VERSION_4) + .build_with_mock_rpc(); + let run = run_split( + platform, + config.clone(), + strategy.clone(), + first_blocks, + second_blocks, + reopen, + ) + .await; + + assert_eq!(run.protocol_version_at_split, TEST_PROTOCOL_VERSION_4); + assert_eq!(run.snapshot.protocol_version, TEST_PROTOCOL_VERSION_4); + assert_eq!( + run.fee_history_at_split, + vec![(0, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE)] + ); + assert_eq!( + run.snapshot.fee_history, + vec![(0, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE)], + "an epoch change within one generation must not add a history entry" + ); + assert_deleted_earlier_document(&run.activity, first_blocks, first_blocks + 1); + runs.push(run); + } + + let reopened = runs.pop().expect("reopened run"); + let continuous = runs.pop().expect("continuous run"); + assert_runs_agree(&continuous, &reopened); + } +} diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs index 1e963cb1cbd..c562545fdce 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs @@ -5,6 +5,7 @@ mod comprehensive_tests; mod core_height_increase; mod core_update_tests; mod data_contract_history_tests; +mod fee_version_boundary_tests; mod identity_and_document_tests; mod identity_transfer_tests; mod process_proposal_collision_tests; From 25dd38b37c0ec8dac5f95db64cb4035020faeb0b Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:52:16 -0500 Subject: [PATCH 5/8] test(strategy-tests): sign chain locks and keep the workload across the boundary continuation The independent validator path verifies the chain lock of every proposal it did not build, so the boundary simulations sign their chain locks with a distinct chain-lock quorum type. The harness also puts the deployed start contracts back into the strategy after remapping the operations, so the continuation clears them instead of asserting they are gone; otherwise the second segment would redeploy the contract and stop touching the documents written before the split. Co-Authored-By: Claude Fable 5.1 --- .../test_cases/fee_version_boundary_tests.rs | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs index 8428770cd45..ff4040f9cf7 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs @@ -117,7 +117,10 @@ mod tests { quorum_size: 30, ..Default::default() }, - chain_lock: ChainLockConfig::default_100_67(), + // Chain locks need their own quorum type: the harness only signs them + // (and the independent validator path only accepts them) when the + // chain-lock quorums are distinct from the validator set. + chain_lock: ChainLockConfig::default(), instant_lock: InstantLockConfig::default_100_67(), execution: ExecutionConfig { verify_sum_trees: true, @@ -197,14 +200,17 @@ mod tests { total_hpmns: 50, extra_normal_mns: 0, validator_quorum_count: 24, - chain_lock_quorum_count: 24, + chain_lock_quorum_count: 4, upgrading_info, proposer_strategy: Default::default(), rotate_quorums: false, failure_testing: None, query_testing: None, verify_state_transition_results: true, + // The validator path verifies the chain lock of every proposal it did + // not build, so the locks must carry a real quorum signature. independent_process_proposal_verification: true, + sign_chain_locks: true, ..Default::default() } } @@ -295,10 +301,10 @@ mod tests { /// saved state instead of the in-memory one. /// /// The continuation is workload-preserving: the mutated strategy of the - /// first segment (its start contracts already deployed and its operations - /// remapped to the deployed contract), its identities, its signer and its - /// nonce counters are handed to the second segment, and both runs reseed - /// the second segment from the same entropy. + /// first segment (its operations remapped to the deployed contract, its + /// start contracts cleared so they are not deployed again), its + /// identities, its signer and its nonce counters are handed to the second + /// segment, and both runs reseed the second segment from the same entropy. async fn run_split( mut platform: TempPlatform, config: PlatformConfig, @@ -345,10 +351,13 @@ mod tests { drop(state); drop(abci_app); - assert!( - continued_strategy.strategy.start_contracts.is_empty(), - "the first segment deploys the start contracts; the continuation must not redeploy them" - ); + // The first segment deployed the start contracts and remapped the + // operations to the deployed ids, but the harness puts the (remapped) + // contracts back into the strategy, and `state_transitions_for_block` + // would deploy them again at the continuation's first block. Take them + // out so the second segment keeps working on the documents the first + // one wrote. + continued_strategy.strategy.start_contracts.clear(); continued_strategy.strategy.signer = Some(signer); let platform = if reopen { From 11da2c9cb043551d54c0e3c124ed13c51df4e8cc Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:52:17 -0500 Subject: [PATCH 6/8] docs(book): describe the mock fee generation, boundary strategy tests and the recorded fee history Co-Authored-By: Claude Fable 5.1 --- book/src/fees/overview.md | 5 +++++ book/src/testing/strategy-tests.md | 30 +++++++++++++++++++++++++ book/src/versioning/platform-version.md | 14 ++++++++++++ 3 files changed, 49 insertions(+) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index a771a14e7e3..fa8c3c79d43 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -535,6 +535,11 @@ Fee versions are stored in the `FEE_VERSIONS` array and looked up by number. The `uses_version_fee_multiplier_permille` field allows a global scaling factor (permille = divide by 1000; a value of 1000 means no change). +The platform state keeps a fee history keyed by epoch: the genesis generation +is recorded when the chain is initialised, and each later generation at the +epoch change that activates it, so refunds can find the schedule that priced +the bytes being removed. + ## Key Source Files | File | Contents | diff --git a/book/src/testing/strategy-tests.md b/book/src/testing/strategy-tests.md index 9faf4905688..9808d9589c0 100644 --- a/book/src/testing/strategy-tests.md +++ b/book/src/testing/strategy-tests.md @@ -291,6 +291,36 @@ let continued = continue_chain_for_strategy( This is invaluable for testing restart scenarios and verifying that state persists correctly across platform restarts. +A continuation is not automatically the same workload as an uninterrupted run: +`continue_chain_for_strategy` reseeds its random generator from the +`StrategyRandomness` it is given, and `state_transitions_for_block` redeploys +any `start_contracts` still in the strategy at the continuation's first block. +To compare a continued run with an uninterrupted one, split both at the same +block, hand the second segment the mutated `strategy` the first segment +returned (its operations are remapped to the deployed contract id; clear its +`start_contracts`, which the harness puts back after deploying them), the +`identities`, the `signer` and the nonce counters, and reseed both +continuations from the same entropy. `state_transitions_per_block` +on the outcome lists what the strategy submitted per block, so a test can prove +the two runs executed one workload before comparing their results. + +### Crossing a Fee-Version Boundary + +`test_cases/fee_version_boundary_tests.rs` upgrades a chain from the latest +protocol version to the `TEST_PLATFORM_V4` mock, whose only difference is a fee +generation with a doubled storage rate (see the versioning chapter's mock +versions section). With `upgrading_info` voting for the mock from block 1 and +60 blocks per epoch, epoch 0 collects the votes, the first block of epoch 1 +locks the mock in, and the first block of epoch 2 activates it and records the +new generation in the fee history. Each simulation runs the same workload +twice, continuously and reopened from the persisted state at the same block +(`store_platform_state: true`, then `TempPlatform::open_with_tempdir`), with +`independent_process_proposal_verification` on so every block is also +validated as a non-proposer. The two runs must agree on the root hash, the +per-block transition results, the identity balances and the fee history, and +at least one document written before the boundary must be deleted after it so +the history-driven refund path runs against persisted bytes. + ## How Strategy Tests Differ from Unit Tests | Aspect | Unit Tests | Strategy Tests | diff --git a/book/src/versioning/platform-version.md b/book/src/versioning/platform-version.md index e7317d3b924..6d1ce5395af 100644 --- a/book/src/versioning/platform-version.md +++ b/book/src/versioning/platform-version.md @@ -371,6 +371,20 @@ This is a clever design: tests can exercise version upgrade logic (like "what happens when we transition from test version 2 to test version 3?") without needing to create real protocol versions. +`TEST_PLATFORM_V4` (`version/mocks/v4_test.rs`) is the latest shipped tables +with one substitution: its fee schedule is `TEST_FEE_VERSION_DOUBLED_STORAGE` +(`version/mocks/fee_doubled_storage_test.rs`), the latest schedule with a new +`fee_version_number` and a doubled storage disk usage rate. No shipped schedule +carries a number other than 1, so this is the only fee generation that +exercises the registry lookup, the epoch-change hook, the saved-state round +trip and the history-driven refund path. Its number sits in the same shifted +range as the mock protocol versions (`(1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES) ++ 1`), and `FeeVersion::get` resolves that range through the test registry only +under `mock-versions`: a production node that finds such a number in its saved +state rejects it instead of falling back to a real schedule. When a new +protocol version ships, move the mock's base to its table so it keeps tracking +the latest behaviour. + ## Why Immutable Snapshots? You might wonder: why not use a mutable configuration object? Why not a From 0ea82674303c253c0a4a78440865ce1aca3222c8 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:35:19 -0500 Subject: [PATCH 7/8] test(strategy-tests): require a delete that crosses the fee-generation activation The upgrade simulation asserted that a document created by the split block was deleted after activation, which a document written and removed under the doubled generation could satisfy. Require a document created before the activation block instead, so the refund path is exercised on bytes stored under the genesis generation and removed under the new one. Co-Authored-By: Claude Fable 5.1 --- .../test_cases/fee_version_boundary_tests.rs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs index ff4040f9cf7..81716fbb3b8 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs @@ -462,30 +462,33 @@ mod tests { } } - /// At least one document written before `split` was deleted at or after - /// `from`, so the history-driven refund path priced bytes that were - /// persisted before the restart. - fn assert_deleted_earlier_document(activity: &DocumentActivity, split: u64, from: u64) { - let created_before_split = activity + /// At least one document written at or before `created_by` was deleted at + /// or after `deleted_from`, so the history-driven refund path priced bytes + /// that were persisted before that point (the restart, or the activation + /// of a new fee generation). + fn assert_deleted_earlier_document( + activity: &DocumentActivity, + created_by: u64, + deleted_from: u64, + ) { + let created_before = activity .created - .range(..=split) + .range(..=created_by) .flat_map(|(_, ids)| ids.iter().copied()) .collect::>(); let deleted_after = activity .deleted - .range(from..) + .range(deleted_from..) .flat_map(|(_, ids)| ids.iter().copied()) .collect::>(); assert!( - !created_before_split.is_empty(), - "expected documents to be created before block {split}" + !created_before.is_empty(), + "expected documents to be created by block {created_by}" ); assert!( - deleted_after - .iter() - .any(|id| created_before_split.contains(id)), - "expected a document created before block {split} to be deleted from block {from}; \ - deleted {deleted_after:?}" + deleted_after.iter().any(|id| created_before.contains(id)), + "expected a document created by block {created_by} to be deleted from block \ + {deleted_from}; deleted {deleted_after:?}" ); } @@ -562,7 +565,11 @@ mod tests { the epoch that activated it" ); assert_eq!(run.snapshot.fee_history, run.fee_history_at_split); - assert_deleted_earlier_document(&run.activity, first_blocks, activation_block); + // A delete that crosses the boundary: written under the genesis + // generation, removed under the doubled one. + assert_deleted_earlier_document(&run.activity, activation_block - 1, activation_block); + // A delete that crosses the split: written before the restart point, + // removed after it. assert_deleted_earlier_document(&run.activity, first_blocks, first_blocks + 1); runs.push(run); } From 0256a4f8ee7071d38c53b079d02ecaf8a3ccb6b3 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Thu, 24 Sep 2026 11:05:40 -0500 Subject: [PATCH 8/8] test(drive-abci): follow the rebased saved-state and document id rules in the boundary tests After the rebase the latest version writes saved-state structure 1, a record that cannot be decoded on its own, so the round trip now goes through the standalone record for structure 0 and through the structure 1 record rebuilt with its entries. Document ids commit to the identity contract nonce since protocol version 14, so the boundary helper derives the id the transition will carry before it queries and deletes the document. The boundary vectors use the upstream processing helper, which reads the version from the block state, instead of a version-parameterised variant that duplicated it. Co-Authored-By: Claude Fable 5.1 --- .../block_processing_end_events/tests.rs | 23 ++++-- .../src/platform_types/platform_state/mod.rs | 78 ++++++++++++++----- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs index 3acbe47b4dd..742f1f9ec9c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs @@ -1584,7 +1584,7 @@ mod storage_refund_clawback_tests { #[cfg(test)] mod fee_generation_boundary { use crate::execution::validation::state_transition::tests::{ - fetch_expected_identity_balance, process_state_transitions_with_platform_version, + fetch_expected_identity_balance, process_state_transitions, setup_identity_with_system_credits_with_platform_version, }; use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; @@ -1752,6 +1752,17 @@ mod fee_generation_boundary { ) .expect("expected a random document"); + // The id consensus assigns commits to the identity contract nonce, so + // the document the test keeps for its lookup and delete must carry it. + document + .set_id_for_creation( + document_type, + &entropy.0, + identity_contract_nonce, + platform_version, + ) + .expect("expected to set the document id"); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let documents_batch_create_transition = @@ -1770,12 +1781,13 @@ mod fee_generation_boundary { .await .expect("expect to create documents batch transition"); - let (mut fee_results, _) = process_state_transitions_with_platform_version( + // The block helper reads the version from `platform_state`, which every + // boundary vector sets to the version it processes under. + let (mut fee_results, _) = process_state_transitions( platform, &[documents_batch_create_transition], block_info, platform_state, - platform_version, ); assert_credits_balanced(platform, platform_version); @@ -1810,12 +1822,13 @@ mod fee_generation_boundary { .await .expect("expect to create documents batch transition"); - let (mut fee_results, _) = process_state_transitions_with_platform_version( + // The block helper reads the version from `platform_state`, which every + // boundary vector sets to the version it processes under. + let (mut fee_results, _) = process_state_transitions( platform, &[documents_batch_delete_transition], block_info, platform_state, - platform_version, ); assert_credits_balanced(platform, platform_version); diff --git a/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs b/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs index c68e02b286b..7dfa78b7c46 100644 --- a/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs @@ -387,8 +387,9 @@ mod tests { mod fee_history { use super::*; use crate::config::PlatformConfig; - use dpp::block::epoch::Epoch; + use dpp::block::epoch::{Epoch, EpochIndex}; use dpp::fee::default_costs::{EpochCosts, KnownCostItem}; + use dpp::version::fee::FeeVersionNumber; use platform_version::version::mocks::fee_doubled_storage_test::{ TEST_FEE_VERSION_DOUBLED_STORAGE, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE, }; @@ -460,33 +461,37 @@ mod tests { ); } - #[test] - fn should_round_trip_the_test_fee_generation_number_through_saved_state() { + /// A state at the mock version whose history holds the genesis + /// generation and the test generation activated at epoch 3. + fn state_with_a_test_generation_boundary() -> PlatformState { let mut state = fresh_state(TEST_PROTOCOL_VERSION_4); - let first = &PlatformVersion::latest().fee_version; state.previous_fee_versions = CachedEpochIndexFeeVersions::from([ - (GENESIS_EPOCH_INDEX, first.as_static()), + ( + GENESIS_EPOCH_INDEX, + PlatformVersion::latest().fee_version.as_static(), + ), (3, &TEST_FEE_VERSION_DOUBLED_STORAGE), ]); + state + } - let bytes = state.serialize_to_bytes().expect("state serializes"); - let restored = PlatformState::versioned_deserialize_trusted(&bytes, &TEST_PLATFORM_V4) - .expect("state with a test fee generation number deserializes"); + fn fee_history_numbers(state: &PlatformState) -> Vec<(EpochIndex, FeeVersionNumber)> { + state + .previous_fee_versions + .iter() + .map(|(epoch_index, fee_version)| (*epoch_index, fee_version.fee_version_number)) + .collect() + } - let numbers = |state: &PlatformState| { - state - .previous_fee_versions - .iter() - .map(|(epoch_index, fee_version)| { - (*epoch_index, fee_version.fee_version_number) - }) - .collect::>() - }; - assert_eq!(numbers(&restored), numbers(&state)); + fn assert_fee_history_survived(restored: &PlatformState, original: &PlatformState) { + assert_eq!(fee_history_numbers(restored), fee_history_numbers(original)); assert_eq!( - numbers(&restored), + fee_history_numbers(restored), vec![ - (GENESIS_EPOCH_INDEX, first.fee_version_number), + ( + GENESIS_EPOCH_INDEX, + PlatformVersion::latest().fee_version.fee_version_number + ), (3, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE) ] ); @@ -496,12 +501,43 @@ mod tests { for cost_item in COST_ITEMS { assert_eq!( epoch.cost_for_known_cost_item(&restored.previous_fee_versions, cost_item), - epoch.cost_for_known_cost_item(&state.previous_fee_versions, cost_item), + epoch.cost_for_known_cost_item(&original.previous_fee_versions, cost_item), "epoch {epoch_index} costs must survive the saved-state round trip" ); } } } + + /// The standalone record (saved-state structure 0, also written beside + /// every checkpoint) resolves a test generation number through the + /// registry when it is read back. + #[test] + fn should_round_trip_the_test_fee_generation_number_through_saved_state() { + let state = state_with_a_test_generation_boundary(); + + let bytes = state + .serialize_standalone_to_bytes() + .expect("state serializes"); + let restored = PlatformState::versioned_deserialize_trusted(&bytes, &TEST_PLATFORM_V4) + .expect("state with a test fee generation number deserializes"); + + assert_fee_history_survived(&restored, &state); + } + + /// The per-block record of saved-state structure 1, the one the latest + /// version writes, carries the same numbers and resolves them the same + /// way when the state is rebuilt from the record and its entries. + #[test] + fn should_round_trip_the_test_fee_generation_number_through_the_structure_1_record() { + let state = state_with_a_test_generation_boundary(); + + let record = PlatformStateForSavingV2::from(&state); + let restored = record + .into_platform_state(Vec::new(), Vec::new()) + .expect("state with a test fee generation number rebuilds from its record"); + + assert_fee_history_survived(&restored, &state); + } } mod versioned_deserialize {