From dc140ee8b17d8efd15aea81dd0808a2edb3805af Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Fri, 11 Sep 2026 20:13:54 -0500 Subject: [PATCH 01/13] 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 ad8e931bea9..07ee3f8f779 100644 --- a/packages/rs-platform-version/src/version/fee/mod.rs +++ b/packages/rs-platform-version/src/version/fee/mod.rs @@ -20,6 +20,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; @@ -69,6 +73,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}")) }) @@ -81,6 +101,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 @@ -167,3 +195,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..4f984d09377 --- /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::v15::PLATFORM_V15; + +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_V15 +}; diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index d6273f0e766..faf5dc381d2 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")] @@ -69,7 +71,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_V15; @@ -85,7 +88,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!( @@ -113,7 +116,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 0f99658e7d0..dbfbca5cd11 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -513,12 +513,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 4182a765ae08a7411cfd86748cfeb764ecacbac1 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Fri, 11 Sep 2026 20:14:30 -0500 Subject: [PATCH 02/13] 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 61e02898e96..50ccfbd7c3e 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 @@ -237,12 +237,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( @@ -250,7 +264,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 ec35566ab0c..bb5fdf65334 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 @@ -32,8 +32,10 @@ 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::platform_serialization::PLATFORM_BINCODE_CONFIG; 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 4caf50663bef3d58bee422cf74a68ad073bb569f Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:39:26 -0500 Subject: [PATCH 03/13] 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 423ef28050c0a50bd62b7c8e55c4ab0ce4a6cad2 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:44:10 -0500 Subject: [PATCH 04/13] 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 3c232ff5311..204e903743b 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/execution.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/execution.rs @@ -1244,6 +1244,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 80bb161de21..f379a9270a6 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs @@ -3120,6 +3120,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 4190abd35c4..782d879aa2c 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 @@ -6,6 +6,7 @@ mod core_height_increase; mod core_update_tests; mod credit_pool_backfill_tests; mod data_contract_history_tests; +mod fee_version_boundary_tests; mod identity_and_document_tests; mod identity_create_proposal_tests; mod identity_transfer_tests; From a66c793fbccb4ad34f6e6725a47885fd2d9e9ff3 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:52:16 -0500 Subject: [PATCH 05/13] 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 9ec6d87ffdb450feb0b8d12019202409daf03202 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 00:52:17 -0500 Subject: [PATCH 06/13] 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 fe80fecbf40..f946c4d5107 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -564,6 +564,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 32e7db207f5..3c7b2784d15 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 087c6f483d3b5a4e21902649f8c5f6b07e9045a5 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:35:19 -0500 Subject: [PATCH 07/13] 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 509b18da85503d8ba363250bfe598f3291f2e805 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Thu, 24 Sep 2026 11:05:40 -0500 Subject: [PATCH 08/13] 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 bb5fdf65334..629624c868b 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 { From 8e774b9d49e7d8ad6b5335fe394cc6de8bf303dd Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Mon, 26 Oct 2026 13:52:41 -0500 Subject: [PATCH 09/13] test(platform): compare whole fee schedules after the saved-state round trip The round-trip check compared only the five storage cost items, so a registry mistake that kept storage rates but changed another cost would have passed. Compare the schedule each epoch resolves to instead. FeeVersion::get_optional now delegates to get, so the shifted test number range is classified in one place. The default mock registry constant is only used inside platform-version and is now pub(crate). Co-Authored-By: Claude Opus 5.5 --- .../src/platform_types/platform_state/mod.rs | 23 +++++++------------ .../src/version/fee/mod.rs | 14 +---------- .../src/version/protocol_version.rs | 2 +- 3 files changed, 10 insertions(+), 29 deletions(-) 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 629624c868b..931f20ba849 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 @@ -397,14 +397,6 @@ mod tests { 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, @@ -496,15 +488,16 @@ mod tests { ] ); + // Every epoch must resolve to the whole schedule it had before the + // round trip, not only to the same number, so every cost the + // schedule prices is covered. 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(&original.previous_fee_versions, cost_item), - "epoch {epoch_index} costs must survive the saved-state round trip" - ); - } + assert_eq!( + epoch.active_fee_version(&restored.previous_fee_versions), + epoch.active_fee_version(&original.previous_fee_versions), + "epoch {epoch_index} must resolve to the same fee schedule after the saved-state round trip" + ); } } diff --git a/packages/rs-platform-version/src/version/fee/mod.rs b/packages/rs-platform-version/src/version/fee/mod.rs index 07ee3f8f779..697f45a165c 100644 --- a/packages/rs-platform-version/src/version/fee/mod.rs +++ b/packages/rs-platform-version/src/version/fee/mod.rs @@ -100,19 +100,7 @@ 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 - } + Self::get(version).ok() } pub fn first<'a>() -> &'a Self { diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index faf5dc381d2..31da652dbaa 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -71,7 +71,7 @@ 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")] -pub const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = +pub(crate) const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = &[TEST_PLATFORM_V2, TEST_PLATFORM_V3, TEST_PLATFORM_V4]; pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V15; From 0b6b4dc7528f7843c0ef32e43255b47e9ee9329c Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Mon, 26 Oct 2026 14:46:50 -0500 Subject: [PATCH 10/13] docs(book): show the mock version registry with its current default set The mock-versions lookup example still initialised the registry with only test versions 2 and 3. It now reads DEFAULT_PLATFORM_TEST_VERSIONS as the code does and names its three entries, including the fee generation mock TEST_PLATFORM_V4. Co-Authored-By: Claude Opus 5.5 --- book/src/versioning/platform-version.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/book/src/versioning/platform-version.md b/book/src/versioning/platform-version.md index 3c7b2784d15..f8b506060a5 100644 --- a/book/src/versioning/platform-version.md +++ b/book/src/versioning/platform-version.md @@ -360,8 +360,9 @@ specific behaviors without polluting the production constants: { if version >> TEST_PROTOCOL_VERSION_SHIFT_BYTES > 0 { let test_version = version - (1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES); + // DEFAULT_PLATFORM_TEST_VERSIONS is [TEST_PLATFORM_V2, TEST_PLATFORM_V3, TEST_PLATFORM_V4] 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(/* ... */); } } From 22751848bfbb4e7dcebbfa4f3e5033290606e5f4 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Mon, 26 Oct 2026 16:01:46 -0500 Subject: [PATCH 11/13] fix(drive-abci): return an error for an unknown fee version in a saved platform state Both saved-state readers resolved each recorded fee version number with expect, so a number the build does not know (one written by a build with mock versions or with a newer fee generation) crashed the node on restart instead of failing the load. Both now return a corrupted state error naming the epoch and the number, and tests feed an unregistered number through the standalone record and the structure 1 record. Co-Authored-By: Claude Opus 5.5 --- .../src/platform_types/platform_state/mod.rs | 52 ++++++++++++++++++- .../platform_state_for_saving/v1/mod.rs | 37 +++++++------ .../platform_state_for_saving/v2/mod.rs | 26 ++++++---- 3 files changed, 88 insertions(+), 27 deletions(-) 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 931f20ba849..96cb6227275 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 @@ -356,7 +356,7 @@ impl TryFromPlatformVersioned for PlatformState { } PlatformStateForSaving::V1(v1) => { match platform_version.drive_abci.structs.platform_state_structure { - 0 => Ok(PlatformState::from(v1)), + 0 => PlatformState::try_from(v1), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "PlatformState::try_from_platform_versioned(PlatformStateForSavingV1)" @@ -531,6 +531,56 @@ mod tests { assert_fee_history_survived(&restored, &state); } + + /// A number in the test range that no test schedule carries, so the + /// lookup fails even with `mock-versions`, as any number the build + /// does not know fails in production. + const UNKNOWN_FEE_VERSION_NUMBER: FeeVersionNumber = + TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE + 1; + + fn assert_unknown_fee_version_rejected( + result: Result, + ) { + let Err(e) = result else { + panic!("a state with an unknown fee version number must not load"); + }; + let message = e.to_string(); + assert!( + message.contains(&format!( + "fee version {UNKNOWN_FEE_VERSION_NUMBER} for epoch 4" + )), + "unexpected error: {message}" + ); + } + + #[test] + fn should_reject_an_unknown_fee_generation_number_in_a_standalone_record() { + let state = state_with_a_test_generation_boundary(); + let mut record = + PlatformStateForSavingV1::try_from(&state).expect("state converts to its record"); + record + .previous_fee_versions + .insert(4, UNKNOWN_FEE_VERSION_NUMBER); + let bytes = + bincode::encode_to_vec(PlatformStateForSaving::V1(record), PLATFORM_BINCODE_CONFIG) + .expect("record serializes"); + + assert_unknown_fee_version_rejected(PlatformState::versioned_deserialize_trusted( + &bytes, + &TEST_PLATFORM_V4, + )); + } + + #[test] + fn should_reject_an_unknown_fee_generation_number_in_a_structure_1_record() { + let state = state_with_a_test_generation_boundary(); + let mut record = PlatformStateForSavingV2::from(&state); + record + .previous_fee_versions + .insert(4, UNKNOWN_FEE_VERSION_NUMBER); + + assert_unknown_fee_version_rejected(record.into_platform_state(Vec::new(), Vec::new())); + } } mod versioned_deserialize { diff --git a/packages/rs-drive-abci/src/platform_types/platform_state/platform_state_for_saving/v1/mod.rs b/packages/rs-drive-abci/src/platform_types/platform_state/platform_state_for_saving/v1/mod.rs index b91987dbe1e..8229ddeb683 100644 --- a/packages/rs-drive-abci/src/platform_types/platform_state/platform_state_for_saving/v1/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/platform_state/platform_state_for_saving/v1/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::masternode::Masternode; use crate::platform_types::platform_state::accessors::PlatformStateV0Methods; @@ -120,9 +121,25 @@ impl TryFrom for PlatformStateForSavingV1 { } } -impl From for PlatformState { - fn from(value: PlatformStateForSavingV1) -> Self { - PlatformState { +impl TryFrom for PlatformState { + type Error = Error; + + fn try_from(value: PlatformStateForSavingV1) -> Result { + let previous_fee_versions = value + .previous_fee_versions + .into_iter() + .map(|(epoch_index, fee_version_number)| { + let fee_version = FeeVersion::get(fee_version_number).map_err(|e| { + Error::Execution(ExecutionError::CorruptedCachedState(format!( + "the saved platform state records fee version {fee_version_number} \ + for epoch {epoch_index}, which this build does not know: {e}" + ))) + })?; + Ok((epoch_index, fee_version)) + }) + .collect::>()?; + + Ok(PlatformState { genesis_block_info: value.genesis_block_info, last_committed_block_info: value.last_committed_block_info, current_protocol_version_in_consensus: value.current_protocol_version_in_consensus, @@ -150,22 +167,12 @@ impl From for PlatformState { .into_iter() .map(|(k, v)| (ProTxHash::from_byte_array(k.to_buffer()), v.into())) .collect(), - previous_fee_versions: value - .previous_fee_versions - .into_iter() - .map(|(epoch_index, fee_version_number)| { - ( - epoch_index, - FeeVersion::get(fee_version_number) - .expect("expected fee version number to exist"), - ) - }) - .collect(), + previous_fee_versions, // a state read back from disk has not been written in full since heavy_fields_dirty: true, // this record carried the collections itself, so no entries back it on disk masternode_changes: EntryChanges::all(), validator_set_changes: EntryChanges::all(), - } + }) } } diff --git a/packages/rs-drive-abci/src/platform_types/platform_state/platform_state_for_saving/v2/mod.rs b/packages/rs-drive-abci/src/platform_types/platform_state/platform_state_for_saving/v2/mod.rs index 4534c9ddebe..0f06e6df2b1 100644 --- a/packages/rs-drive-abci/src/platform_types/platform_state/platform_state_for_saving/v2/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/platform_state/platform_state_for_saving/v2/mod.rs @@ -214,6 +214,20 @@ impl PlatformStateForSavingV2 { ); } + let previous_fee_versions = self + .previous_fee_versions + .into_iter() + .map(|(epoch_index, fee_version_number)| { + let fee_version = FeeVersion::get(fee_version_number).map_err(|e| { + corrupted(format!( + "the saved platform state records fee version {fee_version_number} \ + for epoch {epoch_index}, which this build does not know: {e}" + )) + })?; + Ok((epoch_index, fee_version)) + }) + .collect::>()?; + Ok(PlatformState { genesis_block_info: self.genesis_block_info, last_committed_block_info: self.last_committed_block_info, @@ -230,17 +244,7 @@ impl PlatformStateForSavingV2 { instant_lock_validating_quorums: self.instant_lock_validating_quorums.into(), full_masternode_list, hpmn_masternode_list, - previous_fee_versions: self - .previous_fee_versions - .into_iter() - .map(|(epoch_index, fee_version_number)| { - ( - epoch_index, - FeeVersion::get(fee_version_number) - .expect("expected fee version number to exist"), - ) - }) - .collect(), + previous_fee_versions, // The record was written under structure 1, so its entries are on // disk and match it: nothing is pending. The flag only drives the // structure 0 store, which starts from a full write after a restart. From e7e4a13128605957ed615281f7bde098566019d9 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Mon, 26 Oct 2026 17:01:34 -0500 Subject: [PATCH 12/13] test(drive-abci): record submitted strategy transitions only when a test asks for them The strategy loop cloned every submitted transition into a per-block map, and the outcome now returns that map, so every strategy run kept a second copy of its whole workload. NetworkStrategy gains record_submitted_state_transitions, off by default; the fee-version boundary tests set it and assert that the map they compare is not empty. Co-Authored-By: Claude Opus 5.5 --- packages/rs-drive-abci/tests/strategy_tests/execution.rs | 4 +++- packages/rs-drive-abci/tests/strategy_tests/strategy.rs | 6 ++++++ .../strategy_tests/test_cases/fee_version_boundary_tests.rs | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive-abci/tests/strategy_tests/execution.rs b/packages/rs-drive-abci/tests/strategy_tests/execution.rs index 204e903743b..d5d20441018 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/execution.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/execution.rs @@ -1029,7 +1029,9 @@ pub(crate) async fn continue_chain_for_strategy<'a>( ) .await; - state_transitions_per_block.insert(block_height, state_transitions.clone()); + if strategy.record_submitted_state_transitions { + state_transitions_per_block.insert(block_height, state_transitions.clone()); + } let proposed_version = proposer_versions .as_ref() diff --git a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs index f379a9270a6..81272ac3797 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs @@ -359,6 +359,10 @@ pub struct NetworkStrategy { /// are reproducible; tests exercising behaviour that keys on how old a /// block is relative to the wall clock (checkpoints) set it near now. pub start_time_ms: u64, + /// Keep every submitted state transition in + /// `ChainExecutionOutcome::state_transitions_per_block`. Off by default so + /// runs that never read them do not retain a copy of the whole workload. + pub record_submitted_state_transitions: bool, } impl Default for NetworkStrategy { @@ -384,6 +388,7 @@ impl Default for NetworkStrategy { sign_chain_locks: false, sign_instant_locks: false, start_time_ms: GENESIS_TIME_MS, + record_submitted_state_transitions: false, } } } @@ -3122,6 +3127,7 @@ pub struct ChainExecutionOutcome<'a> { 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. + /// Empty unless `NetworkStrategy::record_submitted_state_transitions` is set. 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 index 81716fbb3b8..12ed01764d6 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 @@ -210,6 +210,7 @@ mod tests { // 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, + record_submitted_state_transitions: true, sign_chain_locks: true, ..Default::default() } @@ -493,6 +494,10 @@ mod tests { } fn assert_runs_agree(continuous: &SplitRun, reopened: &SplitRun) { + assert!( + !continuous.snapshot.submitted_per_block.is_empty(), + "the runs must record their submitted transitions" + ); assert_eq!( continuous.snapshot.submitted_per_block, reopened.snapshot.submitted_per_block, "both runs must submit the same transitions after the split" From 60b345ed4877c36d3e28a9aa6e69fc923bb055cb Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Mon, 26 Oct 2026 17:47:49 -0500 Subject: [PATCH 13/13] test(drive-abci): match the typed error for an unknown saved fee version The rejection tests only checked the rendered message, so another error carrying the same text would have passed. They now match CorruptedCachedState from both record conversions and the generic protocol error the trusted decoder wraps it in, with the message checked separately. The boundary test module also states that its block-end processing runs with the shared helper's genesis-epoch context and only proves that credits balance. Co-Authored-By: Claude Opus 5.5 --- .../block_processing_end_events/tests.rs | 9 +++++ .../src/platform_types/platform_state/mod.rs | 37 ++++++++++++------- 2 files changed, 33 insertions(+), 13 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 742f1f9ec9c..8c960fa3c87 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 @@ -1581,6 +1581,15 @@ mod storage_refund_clawback_tests { /// 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. +/// +/// The assertions are on transition-level pricing (each fee result and the +/// stored documents' storage flags), which reads the block's epoch and the +/// fee history. Block-end processing runs through the shared helper with its +/// genesis-epoch context, because the blocks follow a fast-forward that never +/// built the later epochs' pool trees; here it only proves that credits +/// balance. Block-end behaviour in the epochs on either side of a boundary +/// is covered by the strategy tests in `fee_version_boundary_tests.rs`, +/// which walk every epoch change through the full block path. #[cfg(test)] mod fee_generation_boundary { use crate::execution::validation::state_transition::tests::{ 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 96cb6227275..b7c8dc4728f 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 @@ -538,13 +538,7 @@ mod tests { const UNKNOWN_FEE_VERSION_NUMBER: FeeVersionNumber = TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE + 1; - fn assert_unknown_fee_version_rejected( - result: Result, - ) { - let Err(e) = result else { - panic!("a state with an unknown fee version number must not load"); - }; - let message = e.to_string(); + fn assert_names_the_unknown_fee_version(message: &str) { assert!( message.contains(&format!( "fee version {UNKNOWN_FEE_VERSION_NUMBER} for epoch 4" @@ -553,6 +547,16 @@ mod tests { ); } + fn assert_corrupted_cached_state(result: Result) { + match result { + Err(Error::Execution(ExecutionError::CorruptedCachedState(message))) => { + assert_names_the_unknown_fee_version(&message) + } + Err(e) => panic!("expected a corrupted cached state error, got {e:?}"), + Ok(_) => panic!("a state with an unknown fee version number must not load"), + } + } + #[test] fn should_reject_an_unknown_fee_generation_number_in_a_standalone_record() { let state = state_with_a_test_generation_boundary(); @@ -561,14 +565,21 @@ mod tests { record .previous_fee_versions .insert(4, UNKNOWN_FEE_VERSION_NUMBER); + + assert_corrupted_cached_state(PlatformState::try_from(record.clone())); + + // The trusted decoder reports conversion errors as generic protocol + // errors carrying the conversion's message. let bytes = bincode::encode_to_vec(PlatformStateForSaving::V1(record), PLATFORM_BINCODE_CONFIG) .expect("record serializes"); - - assert_unknown_fee_version_rejected(PlatformState::versioned_deserialize_trusted( - &bytes, - &TEST_PLATFORM_V4, - )); + match PlatformState::versioned_deserialize_trusted(&bytes, &TEST_PLATFORM_V4) { + Err(ProtocolError::Generic(message)) => { + assert_names_the_unknown_fee_version(&message) + } + Err(e) => panic!("expected a generic protocol error, got {e:?}"), + Ok(_) => panic!("a state with an unknown fee version number must not load"), + } } #[test] @@ -579,7 +590,7 @@ mod tests { .previous_fee_versions .insert(4, UNKNOWN_FEE_VERSION_NUMBER); - assert_unknown_fee_version_rejected(record.into_platform_state(Vec::new(), Vec::new())); + assert_corrupted_cached_state(record.into_platform_state(Vec::new(), Vec::new())); } }