From fc1854d6ed3e77b2d8f03e7a0423924fb3c573ff Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 26 Sep 2026 09:49:30 +0700 Subject: [PATCH] fix(drive-abci)!: claw a storage refund back from the epochs it was priced for (PV14) A refund for data removed in epoch E is priced in E and returns the data's shares of epochs E+1 onward. It is taken out of the epoch storage pools at the next epoch change, where add_distribute_storage_fee_to_epochs_operations v0 restored and subtracted it from the epoch after the then current one. The current epoch kept most of its refunded share and later epochs gave back more than theirs; the total still equalled the refund. v1 (DRIVE_ABCI_METHOD_VERSIONS_V10, protocol version 14) restores and subtracts each pending refund from the epoch after the previous block's epoch, the one every pending refund was priced in. Shares of epochs with no block between the two, and the leftovers, come out of the current epoch. The dpp helper subtract_refunds_priced_in_epoch_from_epoch_credits_collection keeps the end-of-storage-window guard. add_process_epoch_change_operations v0 passes the previous epoch index, which only v1 reads. Co-Authored-By: Claude Opus 5.5 --- packages/rs-dpp/src/fee/epoch/distribution.rs | 298 ++++++++ .../v0/mod.rs | 3 + .../block_processing_end_events/tests.rs | 658 ++++++++++++++++++ .../mod.rs | 13 +- .../v0/mod.rs | 5 +- .../v1/mod.rs | 305 ++++++++ .../drive_abci_method_versions/v10.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 13 + 8 files changed, 1296 insertions(+), 3 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v1/mod.rs diff --git a/packages/rs-dpp/src/fee/epoch/distribution.rs b/packages/rs-dpp/src/fee/epoch/distribution.rs index 3a9f72d7d82..e8d45b6a2a0 100644 --- a/packages/rs-dpp/src/fee/epoch/distribution.rs +++ b/packages/rs-dpp/src/fee/epoch/distribution.rs @@ -136,6 +136,76 @@ pub fn subtract_refunds_from_epoch_credits_collection( Ok(()) } +/// Subtracts a storage refund from `credits_per_epochs`, taking from each epoch the share the +/// refund returned for it. +/// +/// `calculate_storage_fee_refund_amount_and_leftovers` prices a refund in the epoch the data is +/// removed in (`pricing_epoch_index`) and returns the shares of every epoch after it. The +/// clawback runs later, at an epoch change into `current_epoch_index`, so the original storage +/// fee is restored from `pricing_epoch_index + 1` and subtracted per epoch from there. Epochs +/// between the pricing epoch and the current one had no block (a halt skipped them), so their +/// shares are taken from the current epoch, as are the leftovers. A refund priced at the end of +/// the storage window has no epoch left to restore over and is taken whole from the current +/// epoch. +pub fn subtract_refunds_priced_in_epoch_from_epoch_credits_collection( + credits_per_epochs: &mut SignedCreditsPerEpoch, + storage_fee: Credits, + start_epoch_index: EpochIndex, + pricing_epoch_index: EpochIndex, + current_epoch_index: EpochIndex, + epochs_per_era: u16, +) -> Result<(), ProtocolError> { + let first_refunded_epoch_index = + pricing_epoch_index + .checked_add(1) + .ok_or(ProtocolError::Overflow( + "the epoch after the refund pricing epoch is past the last epoch index", + ))?; + + // Data is removed in or after the epoch it was stored in, so its refund is priced there + // too. Should a refund say otherwise, it is taken whole from the current epoch rather than + // restored from before the epoch it was stored in. + let leftovers = if start_epoch_index > pricing_epoch_index { + storage_fee + } else { + refund_storage_fee_to_epochs_map( + storage_fee, + start_epoch_index, + first_refunded_epoch_index, + |epoch_index, epoch_fee_share| { + subtract_from_epoch_credits( + credits_per_epochs, + epoch_index.max(current_epoch_index), + epoch_fee_share, + ) + }, + epochs_per_era, + )? + }; + + if leftovers > 0 { + subtract_from_epoch_credits(credits_per_epochs, current_epoch_index, leftovers)?; + } + + Ok(()) +} + +fn subtract_from_epoch_credits( + credits_per_epochs: &mut SignedCreditsPerEpoch, + epoch_index: EpochIndex, + credits: Credits, +) -> Result<(), ProtocolError> { + let epoch_credits = credits_per_epochs.entry(epoch_index).or_default(); + + *epoch_credits = epoch_credits + .checked_sub_unsigned(credits) + .ok_or(ProtocolError::Overflow( + "updated epoch credits are not fitting to credits min size", + ))?; + + Ok(()) +} + /// Calculates leftovers and amount of credits by distributing storage fees to epochs pub fn calculate_storage_fee_refund_amount_and_leftovers( storage_fee: Credits, @@ -1202,6 +1272,234 @@ mod tests { } } + mod subtract_refunds_priced_in_epoch_from_epoch_credits_collection { + use super::*; + use crate::fee::SignedCredits; + use std::collections::BTreeMap; + + const EPOCHS_PER_ERA: u16 = 40; + + /// The shares a refund priced in `pricing_epoch_index` returned, as the pools they must + /// come back from: each epoch after the pricing epoch gives its own share, and the + /// epochs before `current_epoch_index` give theirs through the current epoch. + fn refunded_shares( + storage_fee: Credits, + start_epoch_index: EpochIndex, + pricing_epoch_index: EpochIndex, + current_epoch_index: EpochIndex, + ) -> BTreeMap { + let mut shares = SignedCreditsPerEpoch::default(); + distribute_storage_fee_to_epochs_collection( + &mut shares, + storage_fee, + start_epoch_index, + EPOCHS_PER_ERA, + ) + .expect("should distribute storage fee"); + + let mut refunded = BTreeMap::new(); + for (epoch_index, share) in shares { + if epoch_index > pricing_epoch_index { + *refunded + .entry(epoch_index.max(current_epoch_index)) + .or_insert(0) -= share; + } + } + refunded + } + + fn claw_back( + refund_amount: Credits, + start_epoch_index: EpochIndex, + pricing_epoch_index: EpochIndex, + current_epoch_index: EpochIndex, + ) -> BTreeMap { + let mut credits_per_epochs = SignedCreditsPerEpoch::default(); + subtract_refunds_priced_in_epoch_from_epoch_credits_collection( + &mut credits_per_epochs, + refund_amount, + start_epoch_index, + pricing_epoch_index, + current_epoch_index, + EPOCHS_PER_ERA, + ) + .expect("should subtract the refund"); + credits_per_epochs.into_iter().collect() + } + + fn priced_refund( + storage_fee: Credits, + start_epoch_index: EpochIndex, + pricing_epoch_index: EpochIndex, + ) -> Credits { + calculate_storage_fee_refund_amount_and_leftovers( + storage_fee, + start_epoch_index, + pricing_epoch_index, + EPOCHS_PER_ERA, + ) + .expect("should price the refund") + .0 + } + + #[test] + fn should_take_each_refunded_share_back_from_its_epoch() { + // Every share of this fee is a whole number of credits, so the fee restored from + // the refund is the one it was priced from + let storage_fee = 10_000_000_000; + let refund = priced_refund(storage_fee, 1, 3); + + let clawed_back = claw_back(refund, 1, 3, 4); + + assert_eq!(clawed_back, refunded_shares(storage_fee, 1, 3, 4)); + // The current epoch gives back its own share, not only the leftovers + assert_eq!(clawed_back[&4], -12_500_000); + assert_eq!( + clawed_back.values().sum::(), + -(refund as SignedCredits) + ); + } + + #[test] + fn should_take_the_shares_of_closed_epochs_from_the_current_epoch() { + let storage_fee = 10_000_000_000; + let refund = priced_refund(storage_fee, 1, 3); + + // Epochs 4 and 5 were skipped + let clawed_back = claw_back(refund, 1, 3, 6); + + assert_eq!(clawed_back, refunded_shares(storage_fee, 1, 3, 6)); + assert!(!clawed_back.contains_key(&4) && !clawed_back.contains_key(&5)); + assert_eq!(clawed_back[&6], -3 * 12_500_000); + assert_eq!( + clawed_back.values().sum::(), + -(refund as SignedCredits) + ); + } + + #[test] + fn should_leave_only_rounding_to_the_current_epoch() { + // The shares of a 482 byte removal are not whole credits. The refund is a sum of + // floored shares, and the fee restored from it through the refunded fraction comes + // out slightly lower, so a later epoch gives back its share or one credit less and + // the current epoch gives back the rest + let storage_fee = 482 * 27_000; + let refund = priced_refund(storage_fee, 1, 3); + let refunded = refunded_shares(storage_fee, 1, 3, 4); + + let clawed_back = claw_back(refund, 1, 3, 4); + + assert_eq!( + clawed_back.keys().collect::>(), + refunded.keys().collect::>() + ); + + let mut rounding = 0; + for (epoch_index, share) in &refunded { + if *epoch_index == 4 { + continue; + } + let shortfall = clawed_back[epoch_index] - share; + assert!( + (0..=1).contains(&shortfall), + "epoch {epoch_index} gave back {} for a share of {}", + -clawed_back[epoch_index], + -share + ); + rounding += shortfall; + } + + assert_eq!(rounding, 1356); + assert_eq!(clawed_back[&4], refunded[&4] - rounding); + assert_eq!( + clawed_back.values().sum::(), + -(refund as SignedCredits) + ); + } + + #[test] + fn should_match_the_current_epoch_clawback_for_a_refund_priced_in_the_current_epoch() { + for (start_epoch_index, epoch_index) in [(0, 42), (1, 3), (24, 43)] { + let refund = priced_refund(1_200_005, start_epoch_index, epoch_index); + + let mut current_epoch_clawback = SignedCreditsPerEpoch::default(); + subtract_refunds_from_epoch_credits_collection( + &mut current_epoch_clawback, + refund, + start_epoch_index, + epoch_index, + EPOCHS_PER_ERA, + ) + .expect("should subtract the refund"); + + assert_eq!( + claw_back(refund, start_epoch_index, epoch_index, epoch_index), + current_epoch_clawback.into_iter().collect() + ); + } + } + + #[test] + fn should_take_the_last_share_of_the_storage_window_from_the_current_epoch() { + // Data stored in epoch 0 is paid for until epoch 1999. Removed in epoch 1998, it + // is refunded the share of epoch 1999 alone, clawed back at the change into it + let window_end_epoch_index = PERPETUAL_STORAGE_ERAS * EPOCHS_PER_ERA; + let refund = priced_refund(10_000_000_000, 0, window_end_epoch_index - 2); + assert_eq!(refund, 312_500); + + let clawed_back = claw_back( + refund, + 0, + window_end_epoch_index - 2, + window_end_epoch_index - 1, + ); + + assert_eq!( + clawed_back, + BTreeMap::from([(window_end_epoch_index - 1, -312_500)]) + ); + } + + #[test] + fn should_take_a_refund_priced_past_the_storage_window_from_the_current_epoch() { + // The pricing epoch is the last epoch of the window, so no epoch after it is left + // to restore the refund over + let window_end_epoch_index = PERPETUAL_STORAGE_ERAS * EPOCHS_PER_ERA; + + let clawed_back = + claw_back(1_000, 0, window_end_epoch_index - 1, window_end_epoch_index); + + assert_eq!( + clawed_back, + BTreeMap::from([(window_end_epoch_index, -1_000)]) + ); + } + + #[test] + fn should_take_a_refund_priced_before_its_storage_epoch_from_the_current_epoch() { + assert_eq!(claw_back(1_000, 5, 3, 4), BTreeMap::from([(4, -1_000)])); + } + + #[test] + fn should_take_nothing_for_a_zero_refund() { + assert!(claw_back(0, 1, 3, 4).is_empty()); + } + + #[test] + fn should_return_an_error_when_the_pricing_epoch_is_the_last_epoch_index() { + let result = subtract_refunds_priced_in_epoch_from_epoch_credits_collection( + &mut SignedCreditsPerEpoch::default(), + 1_000, + 0, + EpochIndex::MAX, + EpochIndex::MAX, + EPOCHS_PER_ERA, + ); + + assert!(matches!(result, Err(ProtocolError::Overflow(_)))); + } + } + mod calculate_storage_fee_refund_amount_and_leftovers { use super::*; diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/add_process_epoch_change_operations/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/add_process_epoch_change_operations/v0/mod.rs index 74a9ff479a5..2d161db6c78 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/add_process_epoch_change_operations/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/add_process_epoch_change_operations/v0/mod.rs @@ -140,9 +140,12 @@ impl Platform { } // Distribute storage fees accumulated during previous epoch + // The previous epoch index is read only by add_distribute_storage_fee_to_epochs_operations + // v1 (protocol version 14 on); v0, selected up to protocol version 13, ignores it let storage_fee_distribution_outcome = self .add_distribute_storage_fee_to_epochs_operations( current_epoch.index, + epoch_info.previous_epoch_index(), transaction, &mut inner_batch, platform_version, 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 266a5091af3..4ec54f0d698 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 @@ -914,3 +914,661 @@ mod refund_tests { ); } } + +#[cfg(test)] +mod storage_refund_clawback_tests { + use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0; + use crate::execution::types::block_execution_context::BlockExecutionContext; + use crate::execution::types::block_fees::v0::BlockFeesV0; + use crate::execution::types::block_state_info::v0::BlockStateInfoV0; + use crate::execution::types::block_state_info::BlockStateInfo; + use crate::execution::validation::state_transition::tests::setup_identity_with_system_credits; + use crate::platform_types::epoch_info::v0::EpochInfoV0; + use crate::platform_types::epoch_info::EpochInfo; + use crate::platform_types::platform_state::PlatformStateV0Methods; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + 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::DataContract; + use dpp::document::DocumentV0Setters; + use dpp::fee::epoch::distribution::{ + calculate_storage_fee_refund_amount_and_leftovers, + distribute_storage_fee_to_epochs_collection, + }; + use dpp::fee::epoch::{perpetual_storage_epochs, CreditsPerEpoch, SignedCreditsPerEpoch}; + use dpp::fee::fee_result::FeeResult; + use dpp::fee::{Credits, SignedCredits}; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::platform_value::Bytes32; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::StateTransition; + use dpp::version::ProtocolVersion; + use drive::util::test_helpers::setup_contract; + use drive::util::test_helpers::test_utils::identities::create_test_masternode_identities; + use platform_version::version::PlatformVersion; + use rand::prelude::StdRng; + use rand::SeedableRng; + use std::collections::{BTreeMap, BTreeSet}; + use std::ops::Range; + + /// A chain driven block by block through state transition processing and the real block + /// end fee processing, so storage is paid, refunded and clawed back as on a network. + struct RefundChain { + platform: TempPlatform, + proposer: [u8; 32], + height: u64, + last_epoch_index: Option, + last_block_time_ms: Option, + } + + impl RefundChain { + fn new(protocol_version: ProtocolVersion) -> Self { + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a known version"); + + let proposer = create_test_masternode_identities( + &platform.drive, + 1, + Some(17), + None, + platform_version, + )[0]; + + Self { + platform, + proposer, + height: 0, + last_epoch_index: None, + last_block_time_ms: None, + } + } + + fn platform_version(&self) -> &'static PlatformVersion { + self.platform + .state + .load() + .current_platform_version() + .expect("expected the state's platform version") + } + + fn epochs_per_era(&self) -> u16 { + self.platform.config.drive.epochs_per_era + } + + /// Runs one block in `epoch_index`. It is an epoch change when the previous block was in + /// another epoch, with that epoch as the previous one, as `EpochInfoV0::calculate` sets + /// it on a network. + fn run_block( + &mut self, + epoch_index: EpochIndex, + state_transitions: &[StateTransition], + ) -> Vec { + let platform_state = self.platform.state.load(); + let platform_version = self.platform_version(); + + self.height += 1; + + let epoch_time_ms = self.platform.config.execution.epoch_time_length_s * 1000; + let block_time_ms = epoch_index as u64 * epoch_time_ms + self.height * 1000; + let core_height = self.height as u32 + 1; + + let epoch_info = match self.last_epoch_index { + None => EpochInfoV0 { + current_epoch_index: epoch_index, + previous_epoch_index: None, + is_epoch_change: true, + }, + Some(last_epoch_index) if last_epoch_index != epoch_index => EpochInfoV0 { + current_epoch_index: epoch_index, + previous_epoch_index: Some(last_epoch_index), + is_epoch_change: true, + }, + Some(_) => EpochInfoV0 { + current_epoch_index: epoch_index, + previous_epoch_index: None, + is_epoch_change: false, + }, + }; + + let block_info = BlockInfo { + time_ms: block_time_ms, + height: self.height, + core_height, + epoch: Epoch::new(epoch_index).expect("expected a valid epoch"), + }; + + let raw_state_transitions = state_transitions + .iter() + .map(|state_transition| { + state_transition + .serialize_to_bytes() + .expect("expected to serialize") + }) + .collect::>(); + + let transaction = self.platform.drive.grove.start_transaction(); + + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &raw_state_transitions, + &platform_state, + &block_info, + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transitions"); + + let block_fees: BlockFeesV0 = processing_result.aggregated_fees().clone().into(); + + let block_execution_context = BlockExecutionContext::V0(BlockExecutionContextV0 { + block_state_info: BlockStateInfo::V0(BlockStateInfoV0 { + height: self.height, + round: 0, + block_time_ms, + previous_block_time_ms: self.last_block_time_ms, + proposer_pro_tx_hash: self.proposer, + core_chain_locked_height: core_height, + block_hash: None, + app_hash: None, + }), + epoch_info: EpochInfo::V0(epoch_info), + unsigned_withdrawal_transactions: Default::default(), + block_address_balance_changes: Default::default(), + block_platform_state: (**platform_state).clone(), + proposer_results: None, + }); + + // verify_sum_trees is on by default, so this fails if the credits do not balance + self.platform + .process_block_fees_and_validate_sum_trees( + &block_execution_context, + block_fees.into(), + &transaction, + platform_version, + ) + .expect("expected to process block fees with balanced credits"); + + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + fast_forward_to_block( + &self.platform, + block_time_ms, + self.height, + core_height, + epoch_index, + false, + ); + + self.last_epoch_index = Some(epoch_index); + self.last_block_time_ms = Some(block_time_ms); + + processing_result.into_execution_results() + } + + fn epoch_storage_pools(&self, epochs: Range) -> BTreeMap { + let platform_version = self.platform_version(); + + // Paid epochs have no pool anymore + epochs + .filter_map(|epoch_index| { + self.platform + .drive + .get_epoch_storage_credits_for_distribution( + &Epoch::new(epoch_index).expect("expected a valid epoch"), + None, + platform_version, + ) + .ok() + .map(|credits| (epoch_index, credits)) + }) + .collect() + } + } + + fn successful_fee_result(result: &StateTransitionExecutionResult) -> FeeResult { + match result { + StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } => { + fee_result.clone() + } + other => panic!("expected a successful execution, got {:?}", other), + } + } + + /// What the epoch change after a removal did to the epoch storage pools, next to the shares + /// the removal's refund was priced from. + struct Clawback { + refund: Credits, + /// Per epoch: credits the pool lost to the refund + clawed_back: BTreeMap, + /// Per epoch: the share the refund returned for it, with the shares of epochs that + /// closed before the clawback epoch (skipped epochs) assigned to the clawback epoch + priced: BTreeMap, + } + + impl Clawback { + /// Epochs where the pool lost something other than the share the refund paid out for it + fn errors(&self) -> BTreeMap { + let epochs = self + .clawed_back + .keys() + .chain(self.priced.keys()) + .copied() + .collect::>(); + + epochs + .into_iter() + .filter_map(|epoch_index| { + let error = self.clawed_back.get(&epoch_index).copied().unwrap_or(0) + - self.priced.get(&epoch_index).copied().unwrap_or(0); + (error != 0).then_some((epoch_index, error)) + }) + .collect() + } + + fn moved(&self) -> SignedCredits { + self.errors() + .values() + .filter(|error| **error > 0) + .sum::() + } + + fn describe(&self) -> String { + // Group consecutive epochs with the same error into runs + let mut runs: Vec<(EpochIndex, EpochIndex, SignedCredits)> = vec![]; + for (epoch_index, error) in self.errors() { + match runs.last_mut() { + Some((_, end, run_error)) if *end + 1 == epoch_index && *run_error == error => { + *end = epoch_index + } + _ => runs.push((epoch_index, epoch_index, error)), + } + } + let total_clawed_back: SignedCredits = self.clawed_back.values().sum(); + let mut out = format!( + "refund {}, clawed back {}, moved {} credits, {} error runs\n", + self.refund, + total_clawed_back, + self.moved(), + runs.len() + ); + for (start, end, error) in runs.iter().take(12) { + out.push_str(&format!( + " epochs {start}..={end}: clawed back {error:+} vs priced share ({})\n", + self.priced.get(start).copied().unwrap_or(0) + )); + } + if runs.len() > 12 { + out.push_str(&format!(" ... {} more runs\n", runs.len() - 12)); + } + out + } + } + + /// Stores a document in `store_epoch`, removes it in `remove_epoch` (in that epoch's first + /// block when `remove_in_epoch_change_block`) and crosses the epoch change into + /// `clawback_epoch`, measuring what that change took from each epoch storage pool. + async fn store_remove_and_claw_back( + protocol_version: ProtocolVersion, + store_epoch: EpochIndex, + remove_epoch: EpochIndex, + remove_in_epoch_change_block: bool, + clawback_epoch: EpochIndex, + ) -> Clawback { + assert!(store_epoch <= remove_epoch && remove_epoch < clawback_epoch); + + let mut chain = RefundChain::new(protocol_version); + let platform_version = chain.platform_version(); + let epochs_per_era = chain.epochs_per_era(); + + let (identity, signer, key) = + setup_identity_with_system_credits(&mut chain.platform, 958, dash_to_credits!(1)); + + let dashpay = setup_contract( + &chain.platform.drive, + "tests/supporting_files/contract/dashpay/dashpay-contract-no-indexes.json", + None, + None, + None::, + None, + Some(platform_version), + ); + + let profile = dashpay + .document_type_for_name("profile") + .expect("expected a profile document type"); + + let mut rng = StdRng::seed_from_u64(433); + + // Genesis block, then the first block of the storage epoch + chain.run_block(0, &[]); + if store_epoch > 0 { + chain.run_block(store_epoch, &[]); + } + + // The removed document, stored next to others so the epoch pools hold more than its + // shares, as they do on a network + let mut documents = vec![]; + let mut creates = vec![]; + for nonce in 2..=6 { + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = profile + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(profile, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + + creates.push( + BatchTransition::new_document_creation_transition_from_document( + document.clone(), + profile, + entropy.0, + &key, + nonce, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected a create transition"), + ); + documents.push(document); + } + + let results = chain.run_block(store_epoch, &creates); + results.iter().for_each(|result| { + successful_fee_result(result); + }); + + let delete = BatchTransition::new_document_deletion_transition_from_document( + documents.remove(0), + profile, + &key, + 7, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected a delete transition"); + + if remove_epoch > store_epoch && !remove_in_epoch_change_block { + chain.run_block(remove_epoch, &[]); + } + let results = chain.run_block(remove_epoch, &[delete]); + let delete_fee_result = successful_fee_result(&results[0]); + + let refunds: CreditsPerEpoch = delete_fee_result + .fee_refunds + .get(identity.id().as_bytes()) + .cloned() + .expect("expected a refund for the owner"); + assert_eq!( + refunds.keys().copied().collect::>(), + vec![store_epoch], + "the refund is keyed by the epoch the storage was paid in" + ); + let refund = refunds[&store_epoch]; + + // Every refund waiting for the next epoch change was priced in the removal epoch + let pending_refunds = chain + .platform + .drive + .fetch_pending_epoch_refunds(None, &platform_version.drive) + .expect("expected pending refunds"); + assert_eq!( + pending_refunds, + CreditsPerEpoch::from_iter([(store_epoch, refund)]) + ); + + // The removed bytes the refund was priced from: the refund is the removal-epoch price of + // those bytes' shares of every epoch after the removal epoch + let credits_per_byte = platform_version + .fee_version + .storage + .storage_disk_usage_credit_per_byte; + let removed_storage_fee_candidates = (1..=10_000u64) + .map(|bytes| bytes * credits_per_byte) + .filter(|storage_fee| { + calculate_storage_fee_refund_amount_and_leftovers( + *storage_fee, + store_epoch, + remove_epoch, + epochs_per_era, + ) + .expect("expected to price the refund") + .0 == refund + }) + .collect::>(); + assert_eq!( + removed_storage_fee_candidates.len(), + 1, + "expected one removed byte count to price this refund" + ); + let removed_storage_fee = removed_storage_fee_candidates[0]; + + let mut shares = SignedCreditsPerEpoch::default(); + distribute_storage_fee_to_epochs_collection( + &mut shares, + removed_storage_fee, + store_epoch, + epochs_per_era, + ) + .expect("expected to distribute"); + let mut priced = BTreeMap::new(); + for (epoch_index, share) in shares { + if epoch_index <= remove_epoch { + continue; + } + // A closed epoch's share is taken from the clawback epoch's pool + *priced.entry(epoch_index.max(clawback_epoch)).or_insert(0) += share; + } + assert_eq!( + priced.values().sum::(), + refund as SignedCredits + ); + + let pools_range = + remove_epoch + 1..clawback_epoch + perpetual_storage_epochs(epochs_per_era) + 1; + + let storage_fees_to_distribute = chain + .platform + .drive + .get_storage_fees_from_distribution_pool(None, platform_version) + .expect("expected the storage distribution pool"); + let pools_before = chain.epoch_storage_pools(pools_range.clone()); + + chain.run_block(clawback_epoch, &[]); + + let pools_after = chain.epoch_storage_pools(pools_range); + + // The same change also distributes the storage fees collected since the last change + let mut distributed = SignedCreditsPerEpoch::default(); + distribute_storage_fee_to_epochs_collection( + &mut distributed, + storage_fees_to_distribute, + clawback_epoch, + epochs_per_era, + ) + .expect("expected to distribute"); + + let clawed_back = pools_after + .iter() + .filter_map(|(epoch_index, after)| { + let before = pools_before.get(epoch_index).copied().unwrap_or(0); + let clawed_back = before as SignedCredits + + distributed.get(epoch_index).copied().unwrap_or(0) + - *after as SignedCredits; + (clawed_back != 0).then_some((*epoch_index, clawed_back)) + }) + .collect(); + + Clawback { + refund, + clawed_back, + priced, + } + } + + /// Asserts every pool gave back the share the refund returned for it, up to the rounding of + /// restoring the storage fee from the refund: a later epoch gives back its share or one + /// credit less, and the clawback epoch gives back the rest. Returns what rounding left to + /// the clawback epoch. + fn assert_clawed_back_as_priced( + clawback: &Clawback, + clawback_epoch: EpochIndex, + ) -> SignedCredits { + let errors = clawback.errors(); + + let mut rounding = 0; + for (epoch_index, error) in &errors { + if *epoch_index == clawback_epoch { + continue; + } + assert_eq!( + *error, + -1, + "epoch {epoch_index} gave back more than a credit off its share: {}", + clawback.describe() + ); + rounding += 1; + } + + assert_eq!( + errors.get(&clawback_epoch).copied().unwrap_or(0), + rounding, + "the clawback epoch gives back its share and the rounding: {}", + clawback.describe() + ); + assert_eq!( + clawback.clawed_back.values().sum::(), + clawback.refund as SignedCredits + ); + + rounding + } + + #[tokio::test] + async fn should_claw_back_each_refunded_share_from_its_epoch() { + let protocol_version = PlatformVersion::latest().protocol_version; + + for (store_epoch, remove_epoch, remove_in_epoch_change_block, rounding) in [ + (1, 1, false, 1358), + (1, 3, false, 1356), + (1, 3, true, 1356), + (2, 45, false, 1315), + ] { + let clawback_epoch = remove_epoch + 1; + + let clawback = store_remove_and_claw_back( + protocol_version, + store_epoch, + remove_epoch, + remove_in_epoch_change_block, + clawback_epoch, + ) + .await; + + assert_eq!( + assert_clawed_back_as_priced(&clawback, clawback_epoch), + rounding + ); + // The epoch the refund is clawed back at gives back its own share too + assert!(clawback.clawed_back[&clawback_epoch] > clawback.priced[&clawback_epoch]); + } + } + + #[tokio::test] + async fn should_claw_back_the_shares_of_skipped_epochs_from_the_current_epoch() { + let protocol_version = PlatformVersion::latest().protocol_version; + + // Removed in epoch 3, clawed back after a halt skipped epochs 4 and 5 + let clawback = store_remove_and_claw_back(protocol_version, 1, 3, false, 6).await; + + assert!(!clawback.clawed_back.contains_key(&4) && !clawback.clawed_back.contains_key(&5)); + assert_eq!(assert_clawed_back_as_priced(&clawback, 6), 1354); + // Epoch 6 gives back the shares of epochs 4, 5 and 6 + assert_eq!(clawback.priced[&6], 3 * 16_267); + } + + #[tokio::test] + async fn should_claw_back_a_refund_at_the_end_of_the_storage_window() { + let protocol_version = PlatformVersion::latest().protocol_version; + + // Stored in epoch 1, the document is paid for until epoch 2000, and removed in epoch + // 1999 it is refunded the share of epoch 2000 alone + for clawback_epoch in [2000, 2001] { + let clawback = + store_remove_and_claw_back(protocol_version, 1, 1999, false, clawback_epoch).await; + + assert_eq!(assert_clawed_back_as_priced(&clawback, clawback_epoch), 0); + assert_eq!( + clawback.clawed_back, + BTreeMap::from([(clawback_epoch, clawback.refund as SignedCredits)]) + ); + } + } + + #[tokio::test] + async fn should_keep_the_clawback_from_the_epoch_after_the_current_one_at_protocol_version_13() + { + // Removed in epoch 3 and clawed back at the change into epoch 4, the refund is restored + // and subtracted from epoch 5: epoch 4 gives back only the leftovers of its share of + // 16267, and every later epoch more than its share + let clawback = store_remove_and_claw_back(13, 1, 3, false, 4).await; + + assert_eq!(clawback.clawed_back[&4], 1_023); + assert_eq!(clawback.priced[&4], 16_267); + assert_eq!(clawback.clawed_back[&5], 16_286); + assert_eq!(clawback.moved(), 15_244); + assert_eq!( + clawback.clawed_back.values().sum::(), + clawback.refund as SignedCredits + ); + + // After a halt skipped epochs 4 and 5, epoch 6 gives back only leftovers as well + let clawback = store_remove_and_claw_back(13, 1, 3, false, 6).await; + + assert_eq!(clawback.moved(), 47_840); + assert_eq!( + clawback.clawed_back.values().sum::(), + clawback.refund as SignedCredits + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/mod.rs index df022d3b7ff..5398a92dbaf 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -17,6 +18,8 @@ impl Platform { /// # Arguments /// /// * `current_epoch_index`: An index indicating the current epoch. + /// * `previous_epoch_index`: The epoch of the block before this epoch change. Every pending + /// refund was priced in it; v1 claws the refunds back from the epochs they were priced for. /// * `transaction`: A GroveDB transaction argument. /// * `batch`: A mutable reference to the GroveDB operation batch. /// * `platform_version`: A reference to the current platform version. @@ -41,6 +44,7 @@ impl Platform { pub(in crate::execution::platform_events) fn add_distribute_storage_fee_to_epochs_operations( &self, current_epoch_index: EpochIndex, + previous_epoch_index: Option, transaction: TransactionArg, batch: &mut GroveDbOpBatch, platform_version: &PlatformVersion, @@ -57,9 +61,16 @@ impl Platform { batch, platform_version, ), + 1 => self.add_distribute_storage_fee_to_epochs_operations_v1( + current_epoch_index, + previous_epoch_index, + transaction, + batch, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "add_distribute_storage_fee_to_epochs_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v0/mod.rs index 8d9eeb54743..431b2a35e28 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v0/mod.rs @@ -112,7 +112,8 @@ mod tests { .set_initial_state_structure(); let transaction = platform.drive.grove.start_transaction(); - let platform_version = PlatformVersion::latest(); + // The last protocol version that selects v0 + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); /* Initial distribution @@ -139,6 +140,7 @@ mod tests { platform .add_distribute_storage_fee_to_epochs_operations( current_epoch_index, + None, Some(&transaction), &mut batch, platform_version, @@ -208,6 +210,7 @@ mod tests { let outcome = platform .add_distribute_storage_fee_to_epochs_operations( current_epoch_index, + Some(current_epoch_index - 1), Some(&transaction), &mut batch, platform_version, diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v1/mod.rs new file mode 100644 index 00000000000..e64ba168856 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v1/mod.rs @@ -0,0 +1,305 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::storage_fee_distribution_outcome; +use crate::platform_types::platform::Platform; +use dpp::block::epoch::EpochIndex; +use dpp::fee::epoch::distribution::{ + distribute_storage_fee_to_epochs_collection, + subtract_refunds_priced_in_epoch_from_epoch_credits_collection, +}; +use dpp::fee::epoch::SignedCreditsPerEpoch; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use drive::util::batch::GroveDbOpBatch; + +impl Platform { + /// Adds operations to the GroveDB op batch which distribute storage fees + /// from the distribution pool and subtract pending refunds + /// Returns distribution leftovers + /// + /// V1 takes each pending refund back from the epochs it was priced for. A refund is priced + /// in the epoch the data is removed in and returns the shares of the epochs after it; every + /// refund pending at an epoch change was priced in `previous_epoch_index`, since the first + /// block of an epoch keeps only its own refunds pending. V0 restored and subtracted from + /// the epoch after the current one instead, so the current epoch kept its refunded share + /// and the later epochs gave back more than theirs. + pub(super) fn add_distribute_storage_fee_to_epochs_operations_v1( + &self, + current_epoch_index: EpochIndex, + previous_epoch_index: Option, + transaction: TransactionArg, + batch: &mut GroveDbOpBatch, + platform_version: &PlatformVersion, + ) -> Result { + let storage_distribution_fees = self + .drive + .get_storage_fees_from_distribution_pool(transaction, platform_version)?; + + let mut credits_per_epochs = SignedCreditsPerEpoch::default(); + + // Distribute from storage distribution pool + let leftovers = distribute_storage_fee_to_epochs_collection( + &mut credits_per_epochs, + storage_distribution_fees, + current_epoch_index, + self.config.drive.epochs_per_era, + )?; + + // Deduct pending refunds from the epochs they were refunded for. Shares of epochs that + // closed before the current one come out of the current epoch + // Leftovers are ignored since they already deducted from Identity's refund amount + + let refunds = self + .drive + .fetch_pending_epoch_refunds(transaction, &platform_version.drive)?; + let refunded_epochs_count = refunds.len() as u16; + + if !refunds.is_empty() { + let Some(pricing_epoch_index) = previous_epoch_index else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "pending storage refunds at an epoch change without a previous epoch", + ))); + }; + + for (epoch_index, credits) in refunds { + subtract_refunds_priced_in_epoch_from_epoch_credits_collection( + &mut credits_per_epochs, + credits, + epoch_index, + pricing_epoch_index, + current_epoch_index, + self.config.drive.epochs_per_era, + )?; + } + } + + self.drive + .add_update_epoch_storage_fee_pools_sequence_operations( + batch, + credits_per_epochs, + transaction, + platform_version, + )?; + + Ok( + storage_fee_distribution_outcome::v0::StorageFeeDistributionOutcome { + total_distributed_storage_fees: storage_distribution_fees, + leftovers, + refunded_epochs_count, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PlatformConfig; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::Epoch; + use dpp::fee::epoch::distribution::calculate_storage_fee_refund_amount_and_leftovers; + use dpp::fee::epoch::{perpetual_storage_epochs, CreditsPerEpoch}; + use dpp::fee::Credits; + use drive::config::DriveConfig; + use drive::drive::credit_pools::epochs::operations_factory::EpochOperations; + use drive::drive::credit_pools::operations::update_storage_fee_distribution_pool_operation; + use drive::drive::Drive; + use drive::grovedb::Transaction; + use drive::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; + use drive::util::batch::DriveOperation; + use std::ops::Range; + + const EPOCHS_PER_ERA: u16 = 20; + + fn setup_platform() -> TempPlatform { + TestPlatformBuilder::new() + .with_config(PlatformConfig { + drive: DriveConfig { + epochs_per_era: EPOCHS_PER_ERA, + ..Default::default() + }, + ..Default::default() + }) + .build_with_mock_rpc() + .set_initial_state_structure() + } + + /// Sets up the epoch change into `current_epoch_index` with `refunds` pending and nothing + /// new to distribute, over epoch pools filled from the current epoch on + fn prepare_epoch_change( + platform: &TempPlatform, + current_epoch_index: EpochIndex, + refunds: CreditsPerEpoch, + transaction: &Transaction, + ) { + let platform_version = PlatformVersion::latest(); + + let mut batch = GroveDbOpBatch::new(); + + // init additional epochs pools as it will be done in epoch_change + let perpetual_storage_epochs = perpetual_storage_epochs(EPOCHS_PER_ERA); + for epoch_index in perpetual_storage_epochs..=perpetual_storage_epochs + current_epoch_index + { + Epoch::new(epoch_index) + .expect("expected a valid epoch") + .add_init_empty_operations(&mut batch) + .expect("should add init operations"); + } + + batch.push( + update_storage_fee_distribution_pool_operation(100_000_000_000) + .expect("should return operation"), + ); + platform + .drive + .grove_apply_batch(batch, false, Some(transaction), &platform_version.drive) + .expect("should apply batch"); + + let mut batch = GroveDbOpBatch::new(); + platform + .add_distribute_storage_fee_to_epochs_operations( + current_epoch_index, + None, + Some(transaction), + &mut batch, + platform_version, + ) + .expect("should distribute storage fee pool"); + batch + .push(update_storage_fee_distribution_pool_operation(0).expect("should add operation")); + + let mut operations = vec![]; + Drive::add_update_pending_epoch_refunds_operations( + &mut operations, + refunds, + &platform_version.drive, + ) + .expect("should update pending epoch refunds"); + operations.push(DriveOperation::GroveDBOpBatch(batch)); + + platform + .drive + .apply_drive_operations( + operations, + true, + &BlockInfo::default(), + Some(transaction), + platform_version, + None, + ) + .expect("should apply batch"); + } + + fn epoch_storage_pools( + platform: &TempPlatform, + epochs: Range, + transaction: &Transaction, + ) -> Vec { + platform + .drive + .get_storage_credits_for_distribution_for_epochs_in_range( + epochs, + Some(transaction), + PlatformVersion::latest(), + ) + .expect("should get storage fees") + } + + #[test] + fn should_claw_back_each_pending_refund_from_the_epochs_it_was_priced_for() { + let platform = setup_platform(); + let transaction = platform.drive.grove.start_transaction(); + let platform_version = PlatformVersion::latest(); + + // Stored in epoch 1 and removed in epoch 2, clawed back at the change into epoch 3. + // Every share of this fee is a whole number of credits + let storage_fee: Credits = 10_000_000_000; + let (refund, _) = + calculate_storage_fee_refund_amount_and_leftovers(storage_fee, 1, 2, EPOCHS_PER_ERA) + .expect("should price the refund"); + + let current_epoch_index = 3; + prepare_epoch_change( + &platform, + current_epoch_index, + CreditsPerEpoch::from_iter([(1, refund)]), + &transaction, + ); + + let pools_range = 0..current_epoch_index + perpetual_storage_epochs(EPOCHS_PER_ERA); + let pools_before = epoch_storage_pools(&platform, pools_range.clone(), &transaction); + + let mut batch = GroveDbOpBatch::new(); + let outcome = platform + .add_distribute_storage_fee_to_epochs_operations( + current_epoch_index, + Some(2), + Some(&transaction), + &mut batch, + platform_version, + ) + .expect("should distribute storage fee pool"); + platform + .drive + .grove_apply_batch(batch, false, Some(&transaction), &platform_version.drive) + .expect("should apply batch"); + + assert_eq!(outcome.refunded_epochs_count, 1); + + let pools_after = epoch_storage_pools(&platform, pools_range.clone(), &transaction); + + let mut shares = SignedCreditsPerEpoch::default(); + distribute_storage_fee_to_epochs_collection(&mut shares, storage_fee, 1, EPOCHS_PER_ERA) + .expect("should distribute storage fee"); + + for (epoch_index, (before, after)) in pools_range.zip(pools_before.iter().zip(&pools_after)) + { + // The refund returned the shares of the epochs after epoch 2 + let refunded_share = if epoch_index > 2 { + shares.get(&epoch_index).copied().unwrap_or(0) as Credits + } else { + 0 + }; + assert_eq!( + before - after, + refunded_share, + "epoch {epoch_index} should give back its refunded share" + ); + } + + // Epoch 3 gives back its own share, not just leftovers + assert_eq!(pools_before[3] - pools_after[3], 25_000_000); + assert_eq!( + pools_before.iter().sum::() - pools_after.iter().sum::(), + refund + ); + } + + #[test] + fn should_refuse_pending_refunds_at_an_epoch_change_without_a_previous_epoch() { + let platform = setup_platform(); + let transaction = platform.drive.grove.start_transaction(); + + prepare_epoch_change( + &platform, + 3, + CreditsPerEpoch::from_iter([(1, 1_000_000)]), + &transaction, + ); + + let result = platform.add_distribute_storage_fee_to_epochs_operations( + 3, + None, + Some(&transaction), + &mut GroveDbOpBatch::new(), + PlatformVersion::latest(), + ); + + assert!(matches!( + result, + Err(Error::Execution(ExecutionError::CorruptedCodeExecution(_))) + )); + } +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index ba88d8f6f50..87e4f810c28 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -20,6 +20,8 @@ use crate::version::drive_abci_versions::drive_abci_method_versions::{ /// bumps `rebroadcast_expired_withdrawal_documents` to 2 so an expired withdrawal whose /// payout is below Core's dust threshold is marked FAILED instead of re-signed forever. /// `decode_raw_state_transitions` 1 refuses bytes left over after a raw state transition. +/// `add_distribute_storage_fee_to_epochs_operations` 1 claws each pending storage refund back +/// from the epochs it was priced for. /// Everything else matches `DRIVE_ABCI_METHOD_VERSIONS_V9`. pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMethodVersions { engine: DriveAbciEngineMethodVersions { @@ -80,7 +82,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet }, fee_pool_inwards_distribution: DriveAbciFeePoolInwardsDistributionMethodVersions { add_distribute_block_fees_into_pools_operations: 0, - add_distribute_storage_fee_to_epochs_operations: 0, + add_distribute_storage_fee_to_epochs_operations: 1, // changed in v14: claws each pending refund back from the epochs it was priced for }, fee_pool_outwards_distribution: DriveAbciFeePoolOutwardsDistributionMethodVersions { add_distribute_fees_from_oldest_unpaid_epoch_pool_to_proposers_operations: 1, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index fd6b4bd0dc1..7cf2f2d63e0 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1192,6 +1192,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// hash. Version 1 also reports a transition whose version is not active /// as `StateTransitionNotActiveError` (10603) instead of a decode failure. /// +/// 47. **A storage refund is clawed back from the epochs it was priced for**: +/// removing data in epoch E refunds its owner the shares of epochs E+1 +/// onward, and the refund waits for the next epoch change to be taken out of +/// the epoch storage pools. `add_distribute_storage_fee_to_epochs_operations` +/// 1 (`DRIVE_ABCI_METHOD_VERSIONS_V10`) restores and subtracts it from the +/// epoch after the previous block's epoch, the one every pending refund was +/// priced in, so each of those epochs gives back its own share. The shares +/// of epochs that closed before the current one, skipped by a halt, and the +/// rounding leftovers come out of the current epoch. Version 0 started from +/// the epoch after the current one, so the current epoch kept most of its +/// refunded share and the later epochs gave back more than theirs. The +/// total taken out equals the refund in both. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's