From 0e9f6fe6ac0660d0137adc21a9bf5c0741650e91 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:31:01 -0500 Subject: [PATCH 01/20] feat(platform)!: introduce protocol version 15 with drive table v10 for storage refund fee history Protocol version 15 is a copy of 14 whose drive table (DRIVE_VERSION_V10) selects calculate_fee v1 and the new identity update slot credit_storage_refunds_to_owners (DRIVE_IDENTITY_METHOD_VERSIONS_V3). Shipped identity tables backfill the slot with None so every shipped protocol version is behaviour-preserving. Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../drive_identity_method_versions/mod.rs | 6 + .../drive_identity_method_versions/v1.rs | 1 + .../drive_identity_method_versions/v2.rs | 1 + .../drive_identity_method_versions/v3.rs | 188 ++++++++++++++++++ .../src/version/drive_versions/mod.rs | 1 + .../src/version/drive_versions/v10.rs | 161 +++++++++++++++ .../rs-platform-version/src/version/mod.rs | 5 +- .../src/version/protocol_version.rs | 4 +- .../rs-platform-version/src/version/v15.rs | 85 ++++++++ 9 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v3.rs create mode 100644 packages/rs-platform-version/src/version/drive_versions/v10.rs create mode 100644 packages/rs-platform-version/src/version/v15.rs diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs index 7b99529799e..e23b58ce75b 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs @@ -2,6 +2,7 @@ use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Default)] pub struct DriveIdentityMethodVersions { @@ -239,4 +240,9 @@ pub struct DriveIdentityUpdateMethodVersions { /// Rewrites a key with a raised total budget or a later expiry (protocol version 14). Keys /// cannot carry limits before v14, so earlier tables keep the slot `None`. pub update_identity_key_limits: OptionalFeatureVersion, + /// Read by `Drive::credit_storage_refunds_to_owners_operations`: credits each recorded + /// owner of a storage refund and reports the amount whose owner has no balance element + /// so the caller can route it to the current epoch's processing pool. `None` before + /// protocol version 15, where no path settles refunds outside a state transition. + pub credit_storage_refunds_to_owners: OptionalFeatureVersion, } diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs index c2e4ba4b933..6e2614ad8cf 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs @@ -119,6 +119,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V1: DriveIdentityMethodVersions = remove_from_identity_balance: 0, refresh_identity_key_reference_operations: 0, update_identity_key_limits: None, + credit_storage_refunds_to_owners: None, }, insert: DriveIdentityInsertMethodVersions { add_new_identity: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs index 0040c073c06..55330459c33 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs @@ -165,6 +165,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = remove_from_identity_balance: 0, refresh_identity_key_reference_operations: 0, update_identity_key_limits: Some(0), + credit_storage_refunds_to_owners: None, }, insert: DriveIdentityInsertMethodVersions { add_new_identity: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v3.rs new file mode 100644 index 00000000000..c95284d9aee --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v3.rs @@ -0,0 +1,188 @@ +use crate::version::drive_versions::drive_identity_method_versions::{ + DriveIdentityContractInfoMethodVersions, DriveIdentityCostEstimationMethodVersions, + DriveIdentityFetchAttributesMethodVersions, DriveIdentityFetchFullIdentityMethodVersions, + DriveIdentityFetchMethodVersions, DriveIdentityFetchPartialIdentityMethodVersions, + DriveIdentityFetchPublicKeyHashesMethodVersions, DriveIdentityInsertMethodVersions, + DriveIdentityKeyHashesToIdentityInsertMethodVersions, DriveIdentityKeysBudgetMethodVersions, + DriveIdentityKeysFetchMethodVersions, DriveIdentityKeysInsertMethodVersions, + DriveIdentityKeysMethodVersions, DriveIdentityKeysProveMethodVersions, + DriveIdentityMethodVersions, DriveIdentityProveMethodVersions, + DriveIdentityUpdateMethodVersions, DriveIdentityWithdrawalDocumentMethodVersions, + DriveIdentityWithdrawalMethodVersions, DriveIdentityWithdrawalTransactionIndexMethodVersions, + DriveIdentityWithdrawalTransactionMethodVersions, + DriveIdentityWithdrawalTransactionQueueMethodVersions, +}; + +/// V3 is protocol version 15's identity-method table. It differs from V2 in +/// one slot: +/// +/// * `update.credit_storage_refunds_to_owners` `None -> Some(0)`: the +/// primitive that credits each recorded owner of a storage refund and +/// reports the amount whose owner has no balance element, so a block +/// lifecycle path (or, later, the state transition refund path) can route +/// that amount to the current epoch's processing pool instead of halting. +/// No key or permission is consulted on any route: a frozen but existing +/// owner is credited, an owner without a balance is settled into the pool. +/// Nothing before v15 settles refunds outside a state transition, so V1 and +/// V2 keep the slot `None`. +pub const DRIVE_IDENTITY_METHOD_VERSIONS_V3: DriveIdentityMethodVersions = + DriveIdentityMethodVersions { + fetch: DriveIdentityFetchMethodVersions { + public_key_hashes: DriveIdentityFetchPublicKeyHashesMethodVersions { + fetch_full_identities_by_unique_public_key_hashes: 0, + fetch_full_identity_by_unique_public_key_hash: 0, + fetch_identity_id_by_unique_public_key_hash: 0, + fetch_identity_ids_by_non_unique_public_key_hash: 0, + fetch_identity_ids_by_unique_public_key_hashes: 0, + fetch_serialized_full_identity_by_unique_public_key_hash: 0, + has_any_of_unique_public_key_hashes: 0, + has_non_unique_public_key_hash: 0, + has_non_unique_public_key_hash_already_for_identity: 0, + has_unique_public_key_hash: 0, + fetch_full_identity_by_non_unique_public_key_hash: 0, + }, + attributes: DriveIdentityFetchAttributesMethodVersions { + revision: 0, + nonce: 0, + identity_contract_nonce: 0, + balance: 0, + balance_include_debt: 0, + negative_balance: 0, + }, + partial_identity: DriveIdentityFetchPartialIdentityMethodVersions { + fetch_identity_revision_with_keys: 0, + fetch_identity_balance_with_keys: 0, + fetch_identity_balance_with_keys_and_revision: 0, + fetch_identity_with_balance: 0, + fetch_identity_keys: 0, + }, + full_identity: DriveIdentityFetchFullIdentityMethodVersions { + fetch_full_identity: Some(0), + fetch_full_identities: Some(0), + }, + }, + prove: DriveIdentityProveMethodVersions { + full_identity: 0, + full_identities: 0, + identity_nonce: 0, + identity_contract_nonce: 0, + identities_contract_keys: 0, + prove_full_identities_by_unique_public_key_hashes: 0, + prove_full_identity_by_unique_public_key_hash: 0, + prove_identity_id_by_unique_public_key_hash: 0, + prove_identity_ids_by_unique_public_key_hashes: 0, + prove_full_identity_by_non_unique_public_key_hash: 0, + }, + keys: DriveIdentityKeysMethodVersions { + fetch: DriveIdentityKeysFetchMethodVersions { + fetch_all_current_identity_keys: 0, + fetch_all_identity_keys: 0, + fetch_identities_all_keys: 0, + fetch_identity_keys: 0, + fetch_identities_contract_keys: 0, + }, + prove: DriveIdentityKeysProveMethodVersions { + prove_identities_all_keys: 0, + prove_identity_keys: 0, + }, + insert: DriveIdentityKeysInsertMethodVersions { + create_key_tree_with_keys: 0, + create_new_identity_key_query_trees: 0, + insert_key_searchable_references: 0, + insert_key_to_storage: 0, + insert_new_non_unique_key: 1, + insert_new_unique_key: 1, + replace_key_in_storage: 0, + }, + insert_key_hash_identity_reference: + DriveIdentityKeyHashesToIdentityInsertMethodVersions { + add_estimation_costs_for_insert_non_unique_public_key_hash_reference: 0, + add_estimation_costs_for_insert_unique_public_key_hash_reference: 0, + insert_non_unique_public_key_hash_reference_to_identity: 0, + insert_reference_to_non_unique_key: 0, + insert_reference_to_unique_key: 0, + insert_unique_public_key_hash_reference_to_identity: 0, + }, + budget: DriveIdentityKeysBudgetMethodVersions { + insert_identity_key_budget: Some(0), + fetch_identity_key_remaining_budget: Some(0), + deduct_from_identity_key_budget: Some(0), + add_estimation_costs_for_key_budgets: Some(0), + fetch_identity_keys_remaining_budgets: Some(0), + prove_identity_keys_remaining_budgets: Some(0), + add_to_identity_key_budget: Some(0), + }, + }, + update: DriveIdentityUpdateMethodVersions { + update_identity_revision: 0, + merge_identity_nonce: 0, + update_identity_negative_credit_operation: 0, + initialize_identity_revision: 0, + disable_identity_keys: 1, + re_enable_identity_keys: 0, + add_new_non_unique_keys_to_identity: 0, + add_new_unique_keys_to_identity: 0, + add_new_keys_to_identity: 0, + insert_identity_balance: 0, + initialize_negative_identity_balance: 0, + add_to_identity_balance: 0, + add_to_previous_balance: 0, + apply_balance_change_from_fee_to_identity: 0, + remove_from_identity_balance: 0, + refresh_identity_key_reference_operations: 0, + update_identity_key_limits: Some(0), + credit_storage_refunds_to_owners: Some(0), // new in v15: credits recorded refund owners, reports the unrouted amount for the processing pool + }, + insert: DriveIdentityInsertMethodVersions { + add_new_identity: 0, + }, + contract_info: DriveIdentityContractInfoMethodVersions { + add_potential_contract_info_for_contract_bounded_key: 1, + refresh_potential_contract_info_key_references: 1, + merge_identity_contract_nonce: 0, + }, + cost_estimation: DriveIdentityCostEstimationMethodVersions { + for_authentication_keys_security_level_in_key_reference_tree: 0, + for_balances: 0, + for_token_balances: 0, + for_token_total_supply: 0, + for_contract_info: 0, + for_contract_info_group: 0, + for_contract_info_group_keys: 0, + for_contract_info_group_key_purpose: 0, + for_keys_for_identity_id: 0, + for_negative_credit: 0, + for_purpose_in_key_reference_tree: 0, + for_root_key_reference_tree: 0, + for_update_revision: 0, + for_token_identity_infos: 0, + for_token_perpetual_distribution: 0, + for_token_once_per_identity_distribution: 0, + for_token_pre_programmed_distribution: 0, + for_root_token_ms_interval_distribution: 0, + for_token_selling_prices: 0, + for_token_contract_infos: 0, + }, + withdrawals: DriveIdentityWithdrawalMethodVersions { + document: DriveIdentityWithdrawalDocumentMethodVersions { + fetch_oldest_withdrawal_documents_by_status: 0, + find_withdrawal_documents_by_status_and_transaction_indices: 1, + }, + transaction: DriveIdentityWithdrawalTransactionMethodVersions { + index: DriveIdentityWithdrawalTransactionIndexMethodVersions { + fetch_next_withdrawal_transaction_index: 0, + add_update_next_withdrawal_transaction_index_operation: 0, + }, + queue: DriveIdentityWithdrawalTransactionQueueMethodVersions { + add_enqueue_untied_withdrawal_transaction_operations: 0, + dequeue_untied_withdrawal_transactions: 0, + remove_broadcasted_withdrawal_transactions_after_completion_operations: 0, + move_broadcasted_withdrawal_transactions_back_to_queue_operations: 0, + }, + }, + calculate_current_withdrawal_limit: 1, // changed in v14: daily maximum is a percentage of the total credits a day ago plus the credit inflows of the last 25 hours + record_total_credits_history: Some(0), // new in v14: total credits history for the day-lagged daily withdrawal limit + fetch_total_credits_in_platform_a_day_ago: Some(0), // new in v14 + record_credit_inflows: Some(0), // new in v14: credit inflows sum tree for the net daily withdrawal limit + }, + }; diff --git a/packages/rs-platform-version/src/version/drive_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/mod.rs index 1d7c32caed6..889baec9f92 100644 --- a/packages/rs-platform-version/src/version/drive_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/mod.rs @@ -30,6 +30,7 @@ pub mod drive_token_method_versions; pub mod drive_verify_method_versions; pub mod drive_vote_method_versions; pub mod v1; +pub mod v10; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs new file mode 100644 index 00000000000..e7713a18891 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -0,0 +1,161 @@ +use crate::version::drive_versions::drive_address_funds_method_versions::v2::DRIVE_ADDRESS_FUNDS_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_contract_group_method_versions::v1::DRIVE_CONTRACT_GROUP_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_contract_method_versions::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_credit_pool_method_versions::v1::CREDIT_POOL_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_document_method_versions::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_group_method_versions::v1::DRIVE_GROUP_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_group_method_versions::DriveShieldedMethodVersions; +use crate::version::drive_versions::drive_grove_method_versions::v1::DRIVE_GROVE_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_identity_method_versions::v3::DRIVE_IDENTITY_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_state_transition_method_versions::v4::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_structure_version::v1::DRIVE_STRUCTURE_V1; +use crate::version::drive_versions::drive_token_method_versions::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_verify_method_versions::v3::DRIVE_VERIFY_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_vote_method_versions::v2::DRIVE_VOTE_METHOD_VERSIONS_V2; +use crate::version::drive_versions::{ + DriveAssetLockMethodVersions, DriveBalancesMethodVersions, DriveBatchOperationsMethodVersion, + DriveEstimatedCostsMethodVersions, DriveFeesMethodVersions, DriveFetchMethodVersions, + DriveInitializationMethodVersions, DriveMethodVersions, DriveOperationsMethodVersion, + DrivePlatformStateMethodVersions, DrivePlatformSystemMethodVersions, + DrivePrefundedSpecializedMethodVersions, DriveProtocolUpgradeVersions, + DriveProveMethodVersions, DriveSavedBlockTransactionsMethodVersions, + DriveSystemEstimationCostsMethodVersions, DriveVersion, +}; +use grovedb_version::version::v4::GROVE_V4; + +/// Drive version 10. +/// Introduced in protocol v15, carrying the fee-history rules for storage +/// refunds: +/// +/// * **Fee history required for refunds** — `fees.calculate_fee` 0 -> 1. +/// The v1 generation consults the block's fee history on every fee +/// version number and returns an internal error for an owner-attributed +/// (sectioned) storage removal without that history, where v0 priced +/// fee version number 1 against an empty map. Every shipped schedule +/// shares number 1 and the same storage rates, so the credits are +/// unchanged; only a missing history is now an error instead of a +/// silent fallback to the first-generation rates. +/// * **Recorded-owner refund credits** — `DRIVE_IDENTITY_METHOD_VERSIONS_V3` +/// turns on `update.credit_storage_refunds_to_owners`, the primitive +/// block lifecycle paths use to credit each recorded owner of a refund +/// and report the amount whose owner has no balance element. +/// +/// Everything else matches `DRIVE_VERSION_V9`. +pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { + structure: DRIVE_STRUCTURE_V1, + methods: DriveMethodVersions { + initialization: DriveInitializationMethodVersions { + create_initial_state_structure: 4, // changed in v9: adds the ContractGroups root tree with its groups and members subtrees (v3 added the shielded pool trees) + }, + credit_pools: CREDIT_POOL_METHOD_VERSIONS_V1, + protocol_upgrade: DriveProtocolUpgradeVersions { + clear_version_information: 0, + fetch_versions_with_counter: 0, + fetch_proved_versions_with_counter: 0, + fetch_validator_version_votes: 0, + fetch_proved_validator_version_votes: 0, + remove_validators_proposed_app_versions: 0, + update_validator_proposed_app_version: 0, + }, + prove: DriveProveMethodVersions { + prove_elements: 0, + prove_multiple_state_transition_results: 0, + prove_state_transition: 1, // changed in v9: a document batch proof carries the owner's balance (verify v1) + }, + balances: DriveBalancesMethodVersions { + add_to_system_credits: 0, + add_to_system_credits_operations: 0, + remove_from_system_credits: 0, + remove_from_system_credits_operations: 0, + calculate_total_credits_balance: 2, // ShieldedBalances root tree adds a fifth term to the equation + }, + document: DRIVE_DOCUMENT_METHOD_VERSIONS_V4, // changed in v9: v2 index walkers + v1 update walker (shared-prefix aggregate indexes become insertable) and the detect_ranked_mode slot + vote: DRIVE_VOTE_METHOD_VERSIONS_V2, + contract: DRIVE_CONTRACT_METHOD_VERSIONS_V4, // changed in v9: add_contract_to_storage v1 writes the contract version item beside the contract; update_contract v2 creates the distribution storage and mints the base supply of tokens added by an update + fees: DriveFeesMethodVersions { calculate_fee: 1 }, // changed in v10: fee history required and consulted for every storage refund + estimated_costs: DriveEstimatedCostsMethodVersions { + add_estimation_costs_for_levels_up_to_contract: 0, + add_estimation_costs_for_levels_up_to_contract_document_type_excluded: 0, + add_estimation_costs_for_contested_document_tree_levels_up_to_contract: 0, + add_estimation_costs_for_contested_document_tree_levels_up_to_contract_document_type_excluded: 0, + }, + asset_lock: DriveAssetLockMethodVersions { + add_asset_lock_outpoint: 0, + add_estimation_costs_for_adding_asset_lock: 0, + fetch_asset_lock_outpoint_info: 0, + }, + verify: DRIVE_VERIFY_METHOD_VERSIONS_V3, // changed in v9: a document batch proof carries the owner's balance (verify state transition v1) + identity: DRIVE_IDENTITY_METHOD_VERSIONS_V3, // changed in v10: credit_storage_refunds_to_owners primitive for lifecycle refund settlement + token: DRIVE_TOKEN_METHOD_VERSIONS_V2, // changed in v9: add_pre_programmed_distributions v1 queues the release-time tree shared by a contract's tokens once + platform_system: DrivePlatformSystemMethodVersions { + estimation_costs: DriveSystemEstimationCostsMethodVersions { + for_total_system_credits_update: 0, + }, + }, + operations: DriveOperationsMethodVersion { + rollback_transaction: 0, + drop_cache: 0, + commit_transaction: 0, + apply_partial_batch_low_level_drive_operations: 0, + apply_partial_batch_grovedb_operations: 0, + apply_batch_low_level_drive_operations: 1, // changed: coalesces bound current-key alias writes per batch + apply_batch_grovedb_operations: 0, + }, + state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4, // changed: document_from_action generation 1 stamps built documents with the contract version (create assigns, replace re-assigns; paired with document serialization format 3) + batch_operations: DriveBatchOperationsMethodVersion { + convert_drive_operations_to_grove_operations: 0, + apply_drive_operations: 1, // changed: a batch carrying a storage refund forfeiture (a moderator's document deletion) refunds nobody + }, + platform_state: DrivePlatformStateMethodVersions { + fetch_platform_state_bytes: 0, + store_platform_state_bytes: 0, + fetch_platform_state_recent_bytes: 0, + store_platform_state_recent_bytes: 0, + fetch_platform_state_entries_bytes: 0, + store_platform_state_entry_bytes: 0, + delete_platform_state_entry: 0, + }, + fetch: DriveFetchMethodVersions { fetch_elements: 0 }, + prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { + fetch_single: 0, + prove_single: 0, + add_prefunded_specialized_balance: 0, + add_prefunded_specialized_balance_operations: 1, + deduct_from_prefunded_specialized_balance: 1, + deduct_from_prefunded_specialized_balance_operations: 0, + estimated_cost_for_prefunded_specialized_balance_update: 1, // changed: the prefunded balances layer holds three trees, the voting balances and the two contract fee pot trees + empty_prefunded_specialized_balance: 0, + }, + group: DRIVE_GROUP_METHOD_VERSIONS_V1, + contract_group: DRIVE_CONTRACT_GROUP_METHOD_VERSIONS_V1, + address_funds: DRIVE_ADDRESS_FUNDS_METHOD_VERSIONS_V2, + shielded: DriveShieldedMethodVersions { + insert_note: 0, + insert_nullifiers: 0, + update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, + }, + saved_block_transactions: DriveSavedBlockTransactionsMethodVersions { + store_address_balances: 0, + fetch_address_balances: 0, + prove_compacted_address_balance_changes: 1, + compact_address_balances: 0, + cleanup_expired_address_balances: 0, + max_blocks_before_compaction: 64, + max_addresses_before_compaction: 2048, + }, + }, + grove_methods: DRIVE_GROVE_METHOD_VERSIONS_V1, + // changed in v9: GROVE_V4 activates the indexed-tree batch cleanup + // gates (overwrite inspection + delete-tree actual-type cleanup). + // Indexed trees only exist from protocol v14, so activating the + // stricter cleanup with them costs older versions nothing; staying + // on V3 would let a batch overwrite of a ranked index orphan its + // per-axis secondary storage. + grove_version: GROVE_V4, +}; diff --git a/packages/rs-platform-version/src/version/mod.rs b/packages/rs-platform-version/src/version/mod.rs index 1b1635efb42..ae5fd0887e1 100644 --- a/packages/rs-platform-version/src/version/mod.rs +++ b/packages/rs-platform-version/src/version/mod.rs @@ -1,6 +1,6 @@ mod protocol_version; -use crate::version::v14::PROTOCOL_VERSION_14; +use crate::version::v15::PROTOCOL_VERSION_15; pub use protocol_version::*; use std::ops::RangeInclusive; @@ -20,6 +20,7 @@ pub mod v11; pub mod v12; pub mod v13; pub mod v14; +pub mod v15; pub mod v2; pub mod v3; pub mod v4; @@ -33,5 +34,5 @@ pub type ProtocolVersion = u32; pub const ALL_VERSIONS: RangeInclusive = 1..=LATEST_VERSION; -pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_14; +pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_15; pub const INITIAL_PROTOCOL_VERSION: ProtocolVersion = 1; diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index 00cc470bbc7..d6273f0e766 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -22,6 +22,7 @@ use crate::version::v11::PLATFORM_V11; use crate::version::v12::PLATFORM_V12; use crate::version::v13::PLATFORM_V13; use crate::version::v14::PLATFORM_V14; +use crate::version::v15::PLATFORM_V15; use crate::version::v2::PLATFORM_V2; use crate::version::v3::PLATFORM_V3; use crate::version::v4::PLATFORM_V4; @@ -61,6 +62,7 @@ pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ PLATFORM_V12, PLATFORM_V13, PLATFORM_V14, + PLATFORM_V15, ]; #[cfg(feature = "mock-versions")] @@ -69,7 +71,7 @@ pub static PLATFORM_TEST_VERSIONS: OnceLock> = OnceLock::ne #[cfg(feature = "mock-versions")] const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = &[TEST_PLATFORM_V2, TEST_PLATFORM_V3]; -pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V14; +pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V15; pub const DESIRED_PLATFORM_VERSION: &PlatformVersion = LATEST_PLATFORM_VERSION; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs new file mode 100644 index 00000000000..095de503c4e --- /dev/null +++ b/packages/rs-platform-version/src/version/v15.rs @@ -0,0 +1,85 @@ +use crate::version::consensus_versions::ConsensusVersions; +use crate::version::dpp_versions::dpp_asset_lock_versions::v1::DPP_ASSET_LOCK_VERSIONS_V1; +use crate::version::dpp_versions::dpp_contract_versions::v6::CONTRACT_VERSIONS_V6; +use crate::version::dpp_versions::dpp_costs_versions::v1::DPP_COSTS_VERSIONS_V1; +use crate::version::dpp_versions::dpp_document_versions::v4::DOCUMENT_VERSIONS_V4; +use crate::version::dpp_versions::dpp_factory_versions::v1::DPP_FACTORY_VERSIONS_V1; +use crate::version::dpp_versions::dpp_identity_versions::v1::IDENTITY_VERSIONS_V1; +use crate::version::dpp_versions::dpp_method_versions::v3::DPP_METHOD_VERSIONS_V3; +use crate::version::dpp_versions::dpp_state_transition_conversion_versions::v2::STATE_TRANSITION_CONVERSION_VERSIONS_V2; +use crate::version::dpp_versions::dpp_state_transition_method_versions::v2::STATE_TRANSITION_METHOD_VERSIONS_V2; +use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v3::STATE_TRANSITION_SERIALIZATION_VERSIONS_V3; +use crate::version::dpp_versions::dpp_state_transition_versions::v4::STATE_TRANSITION_VERSIONS_V4; +use crate::version::dpp_versions::dpp_token_versions::v3::TOKEN_VERSIONS_V3; +use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VERSIONS_V5; +use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; +use crate::version::dpp_versions::DPPVersion; +use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; +use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3; +use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIVE_ABCI_STRUCTURE_VERSIONS_V2; +use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v3::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3; +use crate::version::drive_abci_versions::DriveAbciVersion; +use crate::version::drive_versions::v10::DRIVE_VERSION_V10; +use crate::version::fee::v3::FEE_VERSION3; +use crate::version::protocol_version::PlatformVersion; +use crate::version::system_data_contract_versions::v3::SYSTEM_DATA_CONTRACT_VERSIONS_V3; +use crate::version::system_limits::v4::SYSTEM_LIMITS_V4; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; + +/// v15 hosts the storage refund fee-history rules: +/// +/// 1. **Fee history required for storage refunds**: `DRIVE_VERSION_V10` +/// bumps `fees.calculate_fee` to 1. A storage refund for owner-attributed +/// bytes is priced only with the fee history of the block that removes +/// the bytes; the history is consulted on every fee version number and a +/// missing history is an internal error instead of a silent fallback to +/// the first-generation storage rates. Every shipped schedule shares fee +/// version number 1 and the same storage rates, so refund credits are +/// unchanged for every shipped input; the boundary makes the absence of +/// history an error from this version onward. +/// 2. **Recorded-owner refund credits**: the same drive table turns on +/// `identity.update.credit_storage_refunds_to_owners`, the primitive that +/// credits each recorded owner of a refund without consulting any key or +/// permission and reports the amount whose owner has no balance element, +/// so block lifecycle paths can settle it into the current epoch's +/// processing pool. +/// +/// Everything else matches v14. +pub const PLATFORM_V15: PlatformVersion = PlatformVersion { + protocol_version: PROTOCOL_VERSION_15, + drive: DRIVE_VERSION_V10, // changed: calculate_fee v1 (fee history required for refunds) + identity table v3 (credit_storage_refunds_to_owners) + drive_abci: DriveAbciVersion { + structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, + methods: DRIVE_ABCI_METHOD_VERSIONS_V10, + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, + withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, + query: DRIVE_ABCI_QUERY_VERSIONS_V3, + checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, + }, + dpp: DPPVersion { + costs: DPP_COSTS_VERSIONS_V1, + validation: DPP_VALIDATION_VERSIONS_V5, + state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, + state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, + state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, + state_transitions: STATE_TRANSITION_VERSIONS_V4, + contract_versions: CONTRACT_VERSIONS_V6, + document_versions: DOCUMENT_VERSIONS_V4, + identity_versions: IDENTITY_VERSIONS_V1, + voting_versions: VOTING_VERSION_V2, + token_versions: TOKEN_VERSIONS_V3, + asset_lock_versions: DPP_ASSET_LOCK_VERSIONS_V1, + methods: DPP_METHOD_VERSIONS_V3, + factory_versions: DPP_FACTORY_VERSIONS_V1, + }, + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, + fee_version: FEE_VERSION3, + system_limits: SYSTEM_LIMITS_V4, + consensus: ConsensusVersions { + tenderdash_consensus_version: 1, + }, +}; From 2bf793332865e2dba1c114b1a771df3f9fb13633 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:38:19 -0500 Subject: [PATCH 02/20] feat(drive)!: require fee history for storage refunds and credit their recorded owners calculate_fee v1 (consume_to_fees_v1) consults the block's fee history for every owner-attributed storage removal and returns CorruptedCodeExecution without one, on every fee version number; v0 stays byte-identical. The new versioned method credit_storage_refunds_to_owners_operations credits each recorded owner that has a balance element without consulting any key or permission and reports the amount whose owner has no balance for the caller to route to the processing pool. Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../mod.rs | 77 +++++++++++++++++ .../v0/mod.rs | 82 +++++++++++++++++++ .../src/drive/identity/update/methods/mod.rs | 1 + .../src/drive/identity/update/structs/mod.rs | 2 + .../storage_refund_credit_outcome/mod.rs | 33 ++++++++ .../rs-drive/src/fees/calculate_fee/mod.rs | 11 ++- .../rs-drive/src/fees/calculate_fee/v1/mod.rs | 54 ++++++++++++ packages/rs-drive/src/fees/op.rs | 72 ++++++++++++++++ 8 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs create mode 100644 packages/rs-drive/src/fees/calculate_fee/v1/mod.rs diff --git a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs new file mode 100644 index 00000000000..1e107fe2692 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs @@ -0,0 +1,77 @@ +mod v0; + +use crate::drive::identity::update::storage_refund_credit_outcome::StorageRefundCreditOutcome; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::fee::fee_result::refunds::FeeRefunds; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Credits storage refunds to their recorded owners and reports what could + /// not be routed. + /// + /// For every owner in `fee_refunds` except `skip_owner`, the owner's + /// per-epoch credits are summed with checked arithmetic. An owner with a + /// balance element is credited through `add_to_identity_balance_operations`; + /// no key, signature or permission is consulted, so a frozen but existing + /// owner receives its bookkeeping refund. An owner without a balance + /// element (the native proxy for a wiped owner) is not credited and its + /// amount is reported as `routed_to_processing_pool`, for the caller to + /// settle into the current epoch's processing pool with one pool write. + /// The primitive records no pending refunds; the caller does, so the block + /// keeps a single pending-refund and pool write per batch. + /// + /// # Parameters + /// + /// * `fee_refunds` - The refunds to settle, per owner and storage epoch. + /// * `skip_owner` - An owner whose refund the caller settles itself (a + /// state transition payer, whose refund folds into its balance change); + /// `None` on lifecycle paths. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the balance operations are + /// appended to; the caller applies them. + /// * `platform_version` - The platform version. + /// + /// # Returns + /// + /// * `Ok(StorageRefundCreditOutcome)` - The owners credited and the amount + /// routed to the processing pool. + /// * `Err(Error)` - On overflow, a corrupted balance element, or when the + /// method is not active for the platform version. + pub fn credit_storage_refunds_to_owners_operations( + &self, + fee_refunds: &FeeRefunds, + skip_owner: Option<[u8; 32]>, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .identity + .update + .credit_storage_refunds_to_owners + { + Some(0) => self.credit_storage_refunds_to_owners_operations_v0( + fee_refunds, + skip_owner, + transaction, + drive_operations, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "credit_storage_refunds_to_owners_operations".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "credit_storage_refunds_to_owners_operations".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs new file mode 100644 index 00000000000..2ed6589c9c3 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs @@ -0,0 +1,82 @@ +use crate::drive::identity::update::storage_refund_credit_outcome::StorageRefundCreditOutcome; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::fee::fee_result::refunds::FeeRefunds; +use dpp::fee::Credits; +use dpp::prelude::Identifier; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::{BTreeMap, HashMap}; + +impl Drive { + /// Credits each recorded refund owner that has a balance element and + /// reports the rest as routed to the processing pool. See the dispatcher. + #[inline(always)] + pub(super) fn credit_storage_refunds_to_owners_operations_v0( + &self, + fee_refunds: &FeeRefunds, + skip_owner: Option<[u8; 32]>, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let mut credited: BTreeMap = BTreeMap::new(); + let mut routed_to_processing_pool: Credits = 0; + + for (owner_id, credits_per_epoch) in fee_refunds.iter() { + if skip_owner.as_ref() == Some(owner_id) { + continue; + } + + let credits = credits_per_epoch + .values() + .try_fold(0u64, |sum, epoch_credits| sum.checked_add(*epoch_credits)) + .ok_or(ProtocolError::Overflow( + "storage refund credits for one owner overflow", + ))?; + + if credits == 0 { + continue; + } + + // A stateful read: `None` means the balance element does not exist, which + // is the only signal Drive has today that the owner is gone. + let existing_balance = self.fetch_identity_balance_operations( + *owner_id, + true, + transaction, + drive_operations, + platform_version, + )?; + + if existing_balance.is_some() { + let mut estimated_costs_only_with_layer_info = + None::>; + + drive_operations.extend(self.add_to_identity_balance_operations( + *owner_id, + credits, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + + credited.insert(Identifier::from(*owner_id), credits); + } else { + routed_to_processing_pool = routed_to_processing_pool.checked_add(credits).ok_or( + ProtocolError::Overflow( + "storage refund credits routed to the processing pool overflow", + ), + )?; + } + } + + Ok(StorageRefundCreditOutcome { + credited, + routed_to_processing_pool, + }) + } +} diff --git a/packages/rs-drive/src/drive/identity/update/methods/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/mod.rs index ffbf378f148..8dbb3da6aaf 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/mod.rs @@ -4,6 +4,7 @@ mod add_new_unique_keys_to_identity; mod add_to_identity_balance; mod add_to_previous_balance; mod apply_balance_change_from_fee_to_identity; +mod credit_storage_refunds_to_owners_operations; mod disable_identity_keys; pub(crate) mod merge_identity_nonce; mod re_enable_identity_keys; diff --git a/packages/rs-drive/src/drive/identity/update/structs/mod.rs b/packages/rs-drive/src/drive/identity/update/structs/mod.rs index dbdb9a86d2f..71b93a3715f 100644 --- a/packages/rs-drive/src/drive/identity/update/structs/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/structs/mod.rs @@ -2,3 +2,5 @@ pub mod add_to_previous_balance_outcome; /// Applying a balance chance outcome pub mod apply_balance_change_outcome; +/// The outcome of crediting storage refunds to their recorded owners +pub mod storage_refund_credit_outcome; diff --git a/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs b/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs new file mode 100644 index 00000000000..2e42e9bd7b2 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs @@ -0,0 +1,33 @@ +use dpp::fee::Credits; +use dpp::prelude::Identifier; +use std::collections::BTreeMap; + +/// What `Drive::credit_storage_refunds_to_owners_operations` did with a set of +/// storage refunds: which recorded owners were credited and how much could not +/// be routed to any owner. +/// +/// The caller that knows the block's epoch settles `routed_to_processing_pool` +/// into that epoch's processing pool with a single pool write and records the +/// refunds against their storage epochs; the primitive itself never touches +/// the pools. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StorageRefundCreditOutcome { + /// Credits added to each recorded owner's balance, summed over the epochs + /// the owner's bytes were stored in. + pub credited: BTreeMap, + /// Credits whose recorded owner has no balance element. Until a typed + /// owner encoding exists this is the native proxy for a wiped owner, so + /// the caller moves this amount into the current epoch's processing pool. + pub routed_to_processing_pool: Credits, +} + +impl StorageRefundCreditOutcome { + /// The total credited to owners plus the amount routed to the pool. + pub fn total(&self) -> Option { + self.credited + .values() + .try_fold(self.routed_to_processing_pool, |total, credits| { + total.checked_add(*credits) + }) + } +} diff --git a/packages/rs-drive/src/fees/calculate_fee/mod.rs b/packages/rs-drive/src/fees/calculate_fee/mod.rs index f2a6516a5ed..db7f20f418b 100644 --- a/packages/rs-drive/src/fees/calculate_fee/mod.rs +++ b/packages/rs-drive/src/fees/calculate_fee/mod.rs @@ -9,6 +9,7 @@ use dpp::version::PlatformVersion; use enum_map::EnumMap; mod v0; +mod v1; impl Drive { /// Calculates fees for the given operations. Returns the storage and processing costs. @@ -47,9 +48,17 @@ impl Drive { &platform_version.fee_version, previous_fee_versions, ), + 1 => Self::calculate_fee_v1( + base_operations, + drive_operations, + epoch, + epochs_per_era, + &platform_version.fee_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "Drive::calculate_fee".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/fees/calculate_fee/v1/mod.rs b/packages/rs-drive/src/fees/calculate_fee/v1/mod.rs new file mode 100644 index 00000000000..4201dc023bd --- /dev/null +++ b/packages/rs-drive/src/fees/calculate_fee/v1/mod.rs @@ -0,0 +1,54 @@ +use crate::drive::Drive; +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::fees::op::{BaseOp, LowLevelDriveOperation}; +use dpp::block::epoch::Epoch; +use dpp::fee::fee_result::FeeResult; + +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use enum_map::EnumMap; +use platform_version::version::fee::FeeVersion; + +impl Drive { + /// Calculates fees for the given operations. Returns the storage and processing costs. + /// + /// Generation 1: storage refunds are priced through `consume_to_fees_v1`, + /// which requires and consults the fee history for every owner-attributed + /// storage removal. Selected by drive table v10 (protocol version 15). + #[inline(always)] + pub(crate) fn calculate_fee_v1( + base_operations: Option>, + drive_operations: Option>, + epoch: &Epoch, + epochs_per_era: u16, + fee_version: &FeeVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + let mut aggregate_fee_result = FeeResult::default(); + if let Some(base_operations) = base_operations { + for (base_op, count) in base_operations.iter() { + match base_op.cost().checked_mul(*count) { + None => return Err(Error::Fee(FeeError::Overflow("overflow error"))), + Some(cost) => match aggregate_fee_result.processing_fee.checked_add(cost) { + None => return Err(Error::Fee(FeeError::Overflow("overflow error"))), + Some(value) => aggregate_fee_result.processing_fee = value, + }, + } + } + } + + if let Some(drive_operations) = drive_operations { + for drive_fee_result in LowLevelDriveOperation::consume_to_fees_v1( + drive_operations, + epoch, + epochs_per_era, + fee_version, + previous_fee_versions, + )? { + aggregate_fee_result.checked_add_assign(drive_fee_result)?; + } + } + + Ok(aggregate_fee_result) + } +} diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index e501c02f8b2..df4640928a0 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -368,6 +368,78 @@ impl LowLevelDriveOperation { .collect() } + /// Returns a list of the costs of the Drive operations, pricing every + /// owner-attributed storage removal with the fee history of the block + /// that removes the bytes. + /// + /// This is the generation `Drive::calculate_fee` v1 selects. It differs + /// from `consume_to_fees_v0` in one arm: a `SectionedStorageRemoval` + /// always consults `previous_fee_versions` and returns + /// `CorruptedCodeExecution` when none is given, on every fee version + /// number. v0 priced fee version number 1 against an empty history, so a + /// caller that forgot the history silently refunded at the first + /// generation's storage rates; from protocol version 15 that omission is + /// an error, never a fallback to a schedule. + pub fn consume_to_fees_v1( + drive_operations: Vec, + epoch: &Epoch, + epochs_per_era: u16, + fee_version: &FeeVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result, Error> { + drive_operations + .into_iter() + .map(|operation| match operation { + PreCalculatedFeeResult(f) => Ok(f), + FunctionOperation(op) => Ok(FeeResult { + processing_fee: op.cost(fee_version), + ..Default::default() + }), + _ => { + let cost = operation.operation_cost()?; + // There is no need for a checked multiply here because added bytes are u64 and + // storage disk usage credit per byte should never be high enough to cause an overflow + let storage_fee = cost.storage_cost.added_bytes as u64 + * fee_version.storage.storage_disk_usage_credit_per_byte; + let processing_fee = cost.ephemeral_cost(fee_version)?; + let (fee_refunds, removed_bytes_from_system) = + match cost.storage_cost.removed_bytes { + NoStorageRemoval => (FeeRefunds::default(), 0), + BasicStorageRemoval(amount) => { + // this is not always considered an error + (FeeRefunds::default(), amount) + } + SectionedStorageRemoval(mut removal_per_epoch_by_identifier) => { + let system_amount = removal_per_epoch_by_identifier + .remove(&Identifier::default()) + .map_or(0, |a| a.values().sum()); + let previous_fee_versions = previous_fee_versions.ok_or( + Error::Drive(DriveError::CorruptedCodeExecution( + "a storage refund needs the fee history of the block that removes the bytes", + )), + )?; + ( + FeeRefunds::from_storage_removal( + removal_per_epoch_by_identifier, + epoch.index, + epochs_per_era, + previous_fee_versions, + )?, + system_amount, + ) + } + }; + Ok(FeeResult { + storage_fee, + processing_fee, + fee_refunds, + removed_bytes_from_system, + }) + } + }) + .collect() + } + /// Returns the cost of this operation pub fn operation_cost(self) -> Result { match self { From cbb508d5c4f232093667ab914ff615440d07ddf3 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:49:28 -0500 Subject: [PATCH 03/20] test(drive): pin fee history handling of calculate_fee v0 and v1 Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../rs-drive/src/fees/calculate_fee/mod.rs | 95 ++++++ packages/rs-drive/src/fees/op.rs | 303 ++++++++++++++++++ 2 files changed, 398 insertions(+) diff --git a/packages/rs-drive/src/fees/calculate_fee/mod.rs b/packages/rs-drive/src/fees/calculate_fee/mod.rs index db7f20f418b..fafa1b185bc 100644 --- a/packages/rs-drive/src/fees/calculate_fee/mod.rs +++ b/packages/rs-drive/src/fees/calculate_fee/mod.rs @@ -64,3 +64,98 @@ impl Drive { } } } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::fee::epoch::DEFAULT_EPOCHS_PER_ERA; + use grovedb_costs::storage_cost::removal::StorageRemovalPerEpochByIdentifier; + use grovedb_costs::storage_cost::removal::StorageRemovedBytes; + use grovedb_costs::storage_cost::StorageCost; + use grovedb_costs::OperationCost; + use platform_version::version::fee::FeeVersion; + use std::collections::BTreeMap; + + fn owner_attributed_removal() -> Vec { + let mut removal = StorageRemovalPerEpochByIdentifier::default(); + removal.entry([7; 32]).or_default().insert(3, 1000); + vec![LowLevelDriveOperation::CalculatedCostOperation( + OperationCost { + seek_count: 1, + storage_cost: StorageCost { + added_bytes: 0, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::SectionedStorageRemoval(removal), + }, + storage_loaded_bytes: 0, + hash_node_calls: 0, + sinsemilla_hash_calls: 0, + }, + )] + } + + /// Both generations through the dispatcher on the same operations: the + /// last frozen protocol version prices an owner-attributed removal without + /// a fee history, the latest refuses it, and with the history both yield + /// the same fee result. + #[test] + fn should_require_the_fee_history_for_storage_refunds_from_protocol_version_15() { + let frozen_platform_version = PlatformVersion::get(14).expect("protocol version 14"); + let platform_version = PlatformVersion::latest(); + let epoch = Epoch::new(5).expect("epoch 5"); + + let frozen_without_history = Drive::calculate_fee( + None, + Some(owner_attributed_removal()), + &epoch, + DEFAULT_EPOCHS_PER_ERA, + frozen_platform_version, + None, + ) + .expect("protocol version 14 prices fee version number 1 without a history"); + assert!( + frozen_without_history.fee_refunds.get(&[7; 32]).is_some(), + "the shipped generation refunds at the first generation's rates" + ); + + let latest_without_history = Drive::calculate_fee( + None, + Some(owner_attributed_removal()), + &epoch, + DEFAULT_EPOCHS_PER_ERA, + platform_version, + None, + ); + assert!( + matches!( + latest_without_history, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "the latest generation refuses a storage refund without the fee history, got {:?}", + latest_without_history + ); + + let history: CachedEpochIndexFeeVersions = BTreeMap::from([(0u16, FeeVersion::first())]); + let frozen_with_history = Drive::calculate_fee( + None, + Some(owner_attributed_removal()), + &epoch, + DEFAULT_EPOCHS_PER_ERA, + frozen_platform_version, + Some(&history), + ) + .expect("protocol version 14 prices with a history"); + let latest_with_history = Drive::calculate_fee( + None, + Some(owner_attributed_removal()), + &epoch, + DEFAULT_EPOCHS_PER_ERA, + platform_version, + Some(&history), + ) + .expect("the latest generation prices with a history"); + + assert_eq!(frozen_with_history, latest_with_history); + assert_eq!(frozen_with_history, frozen_without_history); + } +} diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index df4640928a0..00b4f1844e9 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -2888,4 +2888,307 @@ mod tests { "expected overflow error when summing large components" ); } + + // --------------------------------------------------------------- + // consume_to_fees_v1: fee history required and consulted for + // owner-attributed storage removals + // --------------------------------------------------------------- + + mod storage_refund_fee_history { + use super::*; + use dpp::fee::epoch::distribution::calculate_storage_fee_refund_amount_and_leftovers; + use dpp::fee::epoch::DEFAULT_EPOCHS_PER_ERA; + use grovedb_costs::storage_cost::removal::StorageRemovalPerEpochByIdentifier; + use platform_version::version::fee::storage::v1::FEE_STORAGE_VERSION1; + use platform_version::version::fee::v1::FEE_VERSION1; + use platform_version::version::PLATFORM_VERSIONS; + + const OWNER: [u8; 32] = [7; 32]; + const OTHER_OWNER: [u8; 32] = [9; 32]; + + /// A schedule no protocol version references, with doubled storage + /// rates, so a test can tell "the history was consulted" apart from + /// "the first generation's rates were used". + static SYNTHETIC_FEE_VERSION_2: FeeVersion = FeeVersion { + fee_version_number: 2, + storage: FeeStorageVersion { + storage_disk_usage_credit_per_byte: 2 * FEE_STORAGE_VERSION1 + .storage_disk_usage_credit_per_byte, + ..FEE_STORAGE_VERSION1 + }, + ..FEE_VERSION1 + }; + + /// One removed element whose bytes are attributed per owner and per + /// storage epoch, the shape grovedb reports for an element carrying + /// owner storage flags. + fn sectioned_removal( + bytes_by_owner: &[([u8; 32], &[(u16, u32)])], + ) -> LowLevelDriveOperation { + let mut removal = StorageRemovalPerEpochByIdentifier::default(); + for (owner, bytes_per_epoch) in bytes_by_owner { + let owner_bytes = removal.entry(*owner).or_default(); + for (epoch_index, bytes) in bytes_per_epoch.iter() { + owner_bytes.insert(*epoch_index, *bytes); + } + } + CalculatedCostOperation(OperationCost { + seek_count: 1, + storage_cost: StorageCost { + added_bytes: 0, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::SectionedStorageRemoval(removal), + }, + storage_loaded_bytes: 0, + hash_node_calls: 0, + sinsemilla_hash_calls: 0, + }) + } + + fn basic_removal(bytes: u32) -> LowLevelDriveOperation { + CalculatedCostOperation(OperationCost { + seek_count: 1, + storage_cost: StorageCost { + added_bytes: 0, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::BasicStorageRemoval(bytes), + }, + storage_loaded_bytes: 0, + hash_node_calls: 0, + sinsemilla_hash_calls: 0, + }) + } + + fn epoch(index: u16) -> Epoch { + Epoch::new(index).expect("test epoch index fits") + } + + fn refund_for(fee_results: &[FeeResult], owner: &[u8; 32], storage_epoch: u16) -> Credits { + *fee_results + .iter() + .find_map(|fee_result| fee_result.fee_refunds.get(owner)) + .expect("the owner should be refunded") + .get(&storage_epoch) + .expect("the storage epoch should be refunded") + } + + #[test] + fn should_reject_a_sectioned_removal_without_fee_history_in_v1() { + let operations = vec![sectioned_removal(&[(OWNER, &[(3, 1000)])])]; + + let result = LowLevelDriveOperation::consume_to_fees_v1( + operations, + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ); + + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "v1 must refuse to price an owner-attributed removal without the fee history, got {:?}", + result + ); + + // v0 keeps its shipped shortcut for fee version number 1. + let operations = vec![sectioned_removal(&[(OWNER, &[(3, 1000)])])]; + LowLevelDriveOperation::consume_to_fees_v0( + operations, + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ) + .expect("v0 prices fee version number 1 against an empty history"); + } + + #[test] + fn should_reject_a_removal_of_unowned_flagged_bytes_without_fee_history_in_v1() { + // Bytes flagged with an epoch but no owner land in the system + // bucket; they are never refunded, but the removal is still + // sectioned and the rule is deliberately uniform: every + // sectioned removal carries the history of the removing block. + let operations = vec![sectioned_removal(&[(Identifier::default(), &[(3, 1000)])])]; + + let result = LowLevelDriveOperation::consume_to_fees_v1( + operations, + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ); + + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + )); + } + + #[test] + fn should_not_need_fee_history_in_v1_for_unflagged_removals() { + let operations = vec![basic_removal(1000)]; + + let fee_results = LowLevelDriveOperation::consume_to_fees_v1( + operations, + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ) + .expect("unflagged bytes are removed from the system, no refund is priced"); + + assert_eq!(fee_results.len(), 1); + assert_eq!(fee_results[0].removed_bytes_from_system, 1000); + assert_eq!(fee_results[0].fee_refunds, FeeRefunds::default()); + } + + #[test] + fn should_consult_the_fee_history_in_v1_even_for_fee_version_number_one() { + // The history says a doubled schedule has been active since epoch 10. + // The bytes were stored at epoch 12 and are removed at epoch 15 under + // a fee version whose number is 1, the exact case v0 shortcuts. + let history: CachedEpochIndexFeeVersions = + BTreeMap::from([(10u16, &SYNTHETIC_FEE_VERSION_2)]); + let stored_bytes = 1000u32; + let storage_epoch = 12u16; + let removal_epoch = 15u16; + + let v0_results = LowLevelDriveOperation::consume_to_fees_v0( + vec![sectioned_removal(&[( + OWNER, + &[(storage_epoch, stored_bytes)], + )])], + &epoch(removal_epoch), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + Some(&history), + ) + .expect("v0 prices"); + let v1_results = LowLevelDriveOperation::consume_to_fees_v1( + vec![sectioned_removal(&[( + OWNER, + &[(storage_epoch, stored_bytes)], + )])], + &epoch(removal_epoch), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + Some(&history), + ) + .expect("v1 prices with the history"); + + let (expected_v0, _) = calculate_storage_fee_refund_amount_and_leftovers( + stored_bytes as Credits * FEE_STORAGE_VERSION1.storage_disk_usage_credit_per_byte, + storage_epoch, + removal_epoch, + DEFAULT_EPOCHS_PER_ERA, + ) + .expect("refund math"); + let (expected_v1, _) = calculate_storage_fee_refund_amount_and_leftovers( + stored_bytes as Credits + * SYNTHETIC_FEE_VERSION_2 + .storage + .storage_disk_usage_credit_per_byte, + storage_epoch, + removal_epoch, + DEFAULT_EPOCHS_PER_ERA, + ) + .expect("refund math"); + + assert_eq!( + refund_for(&v0_results, &OWNER, storage_epoch), + expected_v0, + "v0 ignores the history for fee version number 1 and prices at the first generation" + ); + assert_eq!( + refund_for(&v1_results, &OWNER, storage_epoch), + expected_v1, + "v1 prices with the schedule the history resolves for the epoch" + ); + assert!(expected_v1 > expected_v0); + } + + #[test] + fn should_credit_the_same_refunds_in_v1_as_in_v0_for_every_shipped_platform_version() { + // Every shipped schedule shares fee version number 1 and the same + // storage rates (pinned below), so for every history the epoch + // change hook can build from the shipped versions, v1's credits + // equal v0's: the boundary changes what a missing history does, + // not what a present one yields. + let removal_epoch = 15u16; + let bytes_by_owner: &[([u8; 32], &[(u16, u32)])] = &[ + (OWNER, &[(0, 900), (3, 1200), (7, 64), (12, 5000)]), + (OTHER_OWNER, &[(5, 31), (11, 2048)]), + (Identifier::default(), &[(2, 700)]), + ]; + + for platform_version in PLATFORM_VERSIONS { + for history_epoch in [0u16, 1, 5, 12, 15] { + let history: CachedEpochIndexFeeVersions = + BTreeMap::from([(history_epoch, &platform_version.fee_version)]); + + let v0_results = LowLevelDriveOperation::consume_to_fees_v0( + vec![sectioned_removal(bytes_by_owner), basic_removal(40)], + &epoch(removal_epoch), + DEFAULT_EPOCHS_PER_ERA, + &platform_version.fee_version, + Some(&history), + ) + .expect("v0 prices"); + let v1_results = LowLevelDriveOperation::consume_to_fees_v1( + vec![sectioned_removal(bytes_by_owner), basic_removal(40)], + &epoch(removal_epoch), + DEFAULT_EPOCHS_PER_ERA, + &platform_version.fee_version, + Some(&history), + ) + .expect("v1 prices"); + + assert_eq!( + v0_results, v1_results, + "protocol version {} with a history entry at epoch {} must refund identically in v0 and v1", + platform_version.protocol_version, history_epoch + ); + assert!( + v1_results[0].fee_refunds.get(&OWNER).is_some(), + "the owner's bytes must be refunded" + ); + assert!( + v1_results[0] + .fee_refunds + .get(&Identifier::default()) + .is_none(), + "system bytes are never refunded" + ); + assert_eq!(v1_results[0].removed_bytes_from_system, 700); + } + } + } + + /// The premise of the equality above. A shipped schedule that kept + /// fee version number 1 but changed its storage rates would make v0 + /// (which prices number 1 at the first generation) and v1 (which + /// prices at the schedule the history resolves) diverge on the same + /// input; such a change needs a new fee version number, which both + /// generations already look up in the history. + #[test] + fn should_keep_every_shipped_schedule_on_the_first_generation_storage_rates() { + for platform_version in PLATFORM_VERSIONS { + assert_eq!( + platform_version.fee_version.fee_version_number, + FeeVersion::first().fee_version_number, + "protocol version {} changed its fee version number", + platform_version.protocol_version + ); + assert_eq!( + platform_version.fee_version.storage, + FeeVersion::first().storage, + "protocol version {} changed its storage rates without a new fee version number", + platform_version.protocol_version + ); + } + } + } } From 074d9778dcb6533ff24e4b2da48abac9b374b9ed Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:51:14 -0500 Subject: [PATCH 04/20] test(drive): cover the recorded-owner refund credit primitive Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../v0/mod.rs | 398 ++++++++++++++++++ 1 file changed, 398 insertions(+) diff --git a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs index 2ed6589c9c3..514249ede1d 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs @@ -80,3 +80,401 @@ impl Drive { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::drive::DriveError; + use crate::util::batch::DriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::Epoch; + use dpp::fee::epoch::CreditsPerEpoch; + use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use dpp::identity::Identity; + use grovedb::Transaction; + + const IDENTITY_BALANCE: Credits = 10_000_000; + const PROCESSING_POOL_SEED: Credits = 1_000_000; + + fn insert_identity( + drive: &Drive, + seed: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Identity { + let mut identity = Identity::random_identity(3, Some(seed), platform_version) + .expect("expected a random identity"); + identity.set_balance(IDENTITY_BALANCE); + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + Some(transaction), + platform_version, + ) + .expect("expected to insert the identity"); + identity + } + + fn refunds(entries: &[([u8; 32], &[(u16, Credits)])]) -> FeeRefunds { + let mut fee_refunds = FeeRefunds::default(); + for (owner, credits_per_epoch) in entries { + let mut epochs = CreditsPerEpoch::default(); + for (epoch_index, credits) in credits_per_epoch.iter() { + epochs.insert(*epoch_index, *credits); + } + fee_refunds.0.insert(*owner, epochs); + } + fee_refunds + } + + fn apply( + drive: &Drive, + operations: Vec, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) { + drive + .apply_batch_low_level_drive_operations( + None, + Some(transaction), + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to apply the operations"); + } + + fn balance( + drive: &Drive, + identity_id: [u8; 32], + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Option { + drive + .fetch_identity_balance(identity_id, Some(transaction), platform_version) + .expect("expected to fetch the balance") + } + + #[test] + fn should_credit_each_recorded_owner_by_the_sum_of_its_epochs() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let first = insert_identity(&drive, 1, &transaction, platform_version); + let second = insert_identity(&drive, 2, &transaction, platform_version); + let first_id = first.id().to_buffer(); + let second_id = second.id().to_buffer(); + + let fee_refunds = refunds(&[ + (first_id, &[(0, 300), (4, 700), (9, 1)]), + (second_id, &[(2, 5_000)]), + ]); + + let mut operations = vec![]; + let outcome = drive + .credit_storage_refunds_to_owners_operations( + &fee_refunds, + None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("expected to credit the owners"); + apply(&drive, operations, &transaction, platform_version); + + assert_eq!( + outcome, + StorageRefundCreditOutcome { + credited: BTreeMap::from([(first.id(), 1_001), (second.id(), 5_000)]), + routed_to_processing_pool: 0, + } + ); + assert_eq!( + balance(&drive, first_id, &transaction, platform_version), + Some(IDENTITY_BALANCE + 1_001) + ); + assert_eq!( + balance(&drive, second_id, &transaction, platform_version), + Some(IDENTITY_BALANCE + 5_000) + ); + } + + #[test] + fn should_report_refunds_for_an_owner_without_a_balance_instead_of_failing() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let existing = insert_identity(&drive, 3, &transaction, platform_version); + let existing_id = existing.id().to_buffer(); + let missing_id = [0xAB; 32]; + assert_eq!( + balance(&drive, missing_id, &transaction, platform_version), + None + ); + + let fee_refunds = refunds(&[ + (existing_id, &[(1, 400)]), + (missing_id, &[(1, 250), (3, 50)]), + ]); + + let mut operations = vec![]; + let outcome = drive + .credit_storage_refunds_to_owners_operations( + &fee_refunds, + None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("an owner without a balance is reported, not an error"); + apply(&drive, operations, &transaction, platform_version); + + assert_eq!( + outcome, + StorageRefundCreditOutcome { + credited: BTreeMap::from([(existing.id(), 400)]), + routed_to_processing_pool: 300, + } + ); + assert_eq!(outcome.total(), Some(700)); + assert_eq!( + balance(&drive, existing_id, &transaction, platform_version), + Some(IDENTITY_BALANCE + 400) + ); + assert_eq!( + balance(&drive, missing_id, &transaction, platform_version), + None, + "no balance element is created for the missing owner" + ); + } + + #[test] + fn should_credit_an_owner_whose_keys_are_all_disabled() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let frozen = insert_identity(&drive, 4, &transaction, platform_version); + let frozen_id = frozen.id().to_buffer(); + let key_ids = frozen.public_keys().keys().copied().collect::>(); + assert_eq!(key_ids.len(), 3); + drive + .disable_identity_keys( + frozen_id, + key_ids, + 1_000, + &BlockInfo::default(), + true, + Some(&transaction), + platform_version, + ) + .expect("expected to disable every key"); + + let fee_refunds = refunds(&[(frozen_id, &[(0, 12_345)])]); + + let mut operations = vec![]; + let outcome = drive + .credit_storage_refunds_to_owners_operations( + &fee_refunds, + None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("no key or permission is consulted"); + apply(&drive, operations, &transaction, platform_version); + + assert_eq!(outcome.credited, BTreeMap::from([(frozen.id(), 12_345)])); + assert_eq!(outcome.routed_to_processing_pool, 0); + assert_eq!( + balance(&drive, frozen_id, &transaction, platform_version), + Some(IDENTITY_BALANCE + 12_345) + ); + } + + #[test] + fn should_skip_the_owner_the_caller_settles_itself() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let payer = insert_identity(&drive, 5, &transaction, platform_version); + let other = insert_identity(&drive, 6, &transaction, platform_version); + let payer_id = payer.id().to_buffer(); + let other_id = other.id().to_buffer(); + + let fee_refunds = refunds(&[(payer_id, &[(0, 900)]), (other_id, &[(0, 100)])]); + + let mut operations = vec![]; + let outcome = drive + .credit_storage_refunds_to_owners_operations( + &fee_refunds, + Some(payer_id), + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("expected to credit the other owner"); + apply(&drive, operations, &transaction, platform_version); + + assert_eq!(outcome.credited, BTreeMap::from([(other.id(), 100)])); + assert_eq!(outcome.routed_to_processing_pool, 0); + assert_eq!( + balance(&drive, payer_id, &transaction, platform_version), + Some(IDENTITY_BALANCE), + "the payer's refund folds into its own balance change elsewhere" + ); + assert_eq!( + balance(&drive, other_id, &transaction, platform_version), + Some(IDENTITY_BALANCE + 100) + ); + } + + #[test] + fn should_leave_total_credits_balanced_after_the_caller_records_the_pending_refunds() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + let epoch = Epoch::new(0).expect("epoch 0"); + + let credited_owner = insert_identity(&drive, 7, &transaction, platform_version); + let credited_owner_id = credited_owner.id().to_buffer(); + let missing_owner_id = [0xCD; 32]; + + // Every credit in the system is accounted for before the refunds: two + // identity balances and a seeded processing pool. + let seed_operation = drive + .add_epoch_processing_credits_for_distribution_operation( + &epoch, + PROCESSING_POOL_SEED, + Some(&transaction), + platform_version, + ) + .expect("expected the pool seed operation"); + apply(&drive, vec![seed_operation], &transaction, platform_version); + drive + .add_to_system_credits( + IDENTITY_BALANCE + PROCESSING_POOL_SEED, + Some(&transaction), + platform_version, + ) + .expect("expected to record the system credits"); + assert!(drive + .calculate_total_credits_balance(Some(&transaction), &platform_version.drive) + .expect("expected the balance") + .ok() + .expect("expected a well-formed balance")); + + let fee_refunds = refunds(&[ + (credited_owner_id, &[(0, 100), (1, 50)]), + (missing_owner_id, &[(0, 70)]), + ]); + + // The primitive credits the owner that exists and reports the rest. + let mut operations = vec![]; + let outcome = drive + .credit_storage_refunds_to_owners_operations( + &fee_refunds, + None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("expected to credit the owners"); + assert_eq!(outcome.routed_to_processing_pool, 70); + + // The caller routes the unrouted amount to the epoch's processing pool + // with one pool write and records every refund against its storage + // epoch, exactly as a lifecycle settlement does. + operations.push( + drive + .add_epoch_processing_credits_for_distribution_operation( + &epoch, + outcome.routed_to_processing_pool, + Some(&transaction), + platform_version, + ) + .expect("expected the pool write"), + ); + apply(&drive, operations, &transaction, platform_version); + + let mut pending_refund_operations: Vec = vec![]; + Drive::add_update_pending_epoch_refunds_operations( + &mut pending_refund_operations, + fee_refunds.sum_per_epoch(), + &platform_version.drive, + ) + .expect("expected the pending refund operations"); + drive + .apply_drive_operations( + pending_refund_operations, + true, + &BlockInfo::default(), + Some(&transaction), + platform_version, + None, + ) + .expect("expected to record the pending refunds"); + + assert_eq!( + balance(&drive, credited_owner_id, &transaction, platform_version), + Some(IDENTITY_BALANCE + 150) + ); + assert_eq!( + drive + .get_epoch_processing_credits_for_distribution( + &epoch, + Some(&transaction), + platform_version + ) + .expect("expected the pool balance"), + PROCESSING_POOL_SEED + 70 + ); + assert_eq!( + drive + .fetch_pending_epoch_refunds(Some(&transaction), &platform_version.drive) + .expect("expected the pending refunds"), + CreditsPerEpoch::from_iter([(0, 170), (1, 50)]) + ); + let total = drive + .calculate_total_credits_balance(Some(&transaction), &platform_version.drive) + .expect("expected the balance"); + assert!( + total.ok().expect("expected a well-formed balance"), + "credits moved between the pools and an identity balance must stay conserved: {:?}", + total + ); + } + + #[test] + fn should_not_be_active_before_the_new_drive_table() { + let drive = setup_drive_with_initial_state_structure(None); + let frozen_platform_version = PlatformVersion::get(14).expect("protocol version 14"); + let transaction = drive.grove.start_transaction(); + + let fee_refunds = refunds(&[([1; 32], &[(0, 100)])]); + + let result = drive.credit_storage_refunds_to_owners_operations( + &fee_refunds, + None, + Some(&transaction), + &mut vec![], + frozen_platform_version, + ); + + assert!( + matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + ), + "protocol version 14 has no lifecycle refund settlement, got {:?}", + result + ); + } +} From 10e16d32762279600de16dd7b4a198f5e4f595f5 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:54:54 -0500 Subject: [PATCH 05/20] test(drive): close group actions through the production funnel with fee history Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- packages/rs-drive/src/drive/group/mod.rs | 175 ++++++++++++++++++++--- 1 file changed, 159 insertions(+), 16 deletions(-) diff --git a/packages/rs-drive/src/drive/group/mod.rs b/packages/rs-drive/src/drive/group/mod.rs index 9dfb73d09ee..c7973afdbd3 100644 --- a/packages/rs-drive/src/drive/group/mod.rs +++ b/packages/rs-drive/src/drive/group/mod.rs @@ -16,6 +16,10 @@ pub(crate) mod structure; #[cfg(feature = "server")] mod tests { use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::util::batch::drive_op_batch::GroupOperationType; + use crate::util::batch::DriveOperation; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -27,6 +31,8 @@ mod tests { use dpp::data_contract::group::Group; use dpp::data_contract::v1::DataContractV1; use dpp::data_contract::DataContract; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::fee::fee_result::FeeResult; use dpp::group::action_event::GroupActionEvent; use dpp::group::group_action::v0::GroupActionV0; use dpp::group::group_action::GroupAction; @@ -36,9 +42,52 @@ mod tests { use dpp::identity::Identity; use dpp::serialization::PlatformDeserializableTrusted; use dpp::tokens::token_event::TokenEvent; + use dpp::version::fee::FeeVersion; use dpp::version::PlatformVersion; use std::collections::BTreeMap; + /// Closing a group action moves signer-flagged items out of the active + /// tree, and pricing that removal needs the fee history of the removing + /// block. Production reaches the closing branch only through + /// `apply_drive_operations`, which forwards the block's history; the + /// tests below use the same funnel. + fn fee_history() -> CachedEpochIndexFeeVersions { + BTreeMap::from([(0, FeeVersion::first())]) + } + + /// Closes `action_id` the way production does: as a group operation + /// applied through `apply_drive_operations` with the fee history. + #[allow(clippy::too_many_arguments)] + fn close_group_action_through_production_funnel( + drive: &Drive, + contract_id: Identifier, + initialize_with_insert_action_info: Option, + action_id: Identifier, + signer_identity_id: Identifier, + signer_power: u32, + platform_version: &PlatformVersion, + ) -> Result { + let history = fee_history(); + drive.apply_drive_operations( + vec![DriveOperation::GroupOperation( + GroupOperationType::AddGroupAction { + contract_id, + group_contract_position: 0, + initialize_with_insert_action_info, + action_id, + signer_identity_id, + signer_power, + closes_group_action: true, + }, + )], + true, + &BlockInfo::default(), + None, + platform_version, + Some(&history), + ) + } + /// Helper to create a standard test contract with groups and tokens. fn create_test_contract_with_groups( identity_1_id: Identifier, @@ -575,22 +624,18 @@ mod tests { let platform_version = PlatformVersion::latest(); // Add second signer to bring total power to 3 (meets required_power) - // and close the action - drive - .add_group_action( - contract_id, - 0, - None, // no new action info, existing one will be moved - true, // closes_group_action - action_id, - identity_2_id, - 2, - &BlockInfo::default(), - true, - None, - platform_version, - ) - .expect("expected to close group action"); + // and close the action. Closing moves signer-flagged items, so it goes + // through the production funnel that carries the fee history. + close_group_action_through_production_funnel( + &drive, + contract_id, + None, // no new action info, existing one will be moved + action_id, + identity_2_id, + 2, + platform_version, + ) + .expect("expected to close group action"); // Verify the action is now closed let is_closed = drive @@ -680,6 +725,104 @@ mod tests { ); } + #[test] + fn should_refund_signer_bytes_when_a_group_action_closes() { + let (drive, contract_id, identity_1_id, identity_2_id, action_id) = + setup_drive_with_contract_and_action(); + let platform_version = PlatformVersion::latest(); + + // identity_1 opened the action: its signer sum item and the action + // info are flagged with identity_1 at epoch 0. Closing moves both out + // of the active tree into unflagged closed items, so identity_1 is + // refunded for the removed flagged bytes; identity_2's closing signer + // item is written unflagged and never refundable. + let fee_result = close_group_action_through_production_funnel( + &drive, + contract_id, + None, + action_id, + identity_2_id, + 2, + platform_version, + ) + .expect("expected to close group action"); + + let identity_1_refunds = fee_result + .fee_refunds + .get(identity_1_id.as_bytes()) + .expect("the opening signer's flagged bytes must be refunded"); + assert!( + identity_1_refunds + .get(&0) + .is_some_and(|credits| *credits > 0), + "the refund is recorded against the storage epoch of the moved items: {:?}", + identity_1_refunds + ); + assert!( + fee_result + .fee_refunds + .get(identity_2_id.as_bytes()) + .is_none(), + "the closing signer never stored flagged bytes" + ); + } + + #[test] + fn should_reject_closing_a_group_action_through_the_bare_wrapper_without_fee_history() { + // The bare fee-returning wrapper passes no fee history. Production + // never closes an action through it (the state transition funnel + // forwards the block's history), so from protocol version 15 a + // closing call through the wrapper is a misuse the strict refund rule + // surfaces; the frozen generation keeps pricing at the first + // generation's rates. + let (drive, contract_id, _identity_1_id, identity_2_id, action_id) = + setup_drive_with_contract_and_action(); + let platform_version = PlatformVersion::latest(); + + let result = drive.add_group_action( + contract_id, + 0, + None, + true, + action_id, + identity_2_id, + 2, + &BlockInfo::default(), + true, + None, + platform_version, + ); + + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "closing without fee history must be rejected at the latest version, got {:?}", + result + ); + + let (drive, contract_id, _identity_1_id, identity_2_id, action_id) = + setup_drive_with_contract_and_action(); + let frozen_platform_version = PlatformVersion::get(14).expect("protocol version 14"); + + drive + .add_group_action( + contract_id, + 0, + None, + true, + action_id, + identity_2_id, + 2, + &BlockInfo::default(), + true, + None, + frozen_platform_version, + ) + .expect("protocol version 14 prices the shipped shortcut without a history"); + } + #[test] fn should_close_group_action_with_new_action_info() { let drive = setup_drive_with_initial_state_structure(None); From 7ec7e58e1564b62ffb3294f0cc9ae41d94e264d0 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:58:45 -0500 Subject: [PATCH 06/20] test(drive): pin the protocol 12 schema strip as the recorded refund exception Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- ...trip_unknown_document_schema_properties.rs | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/packages/rs-drive/src/drive/contract/migration/strip_unknown_document_schema_properties.rs b/packages/rs-drive/src/drive/contract/migration/strip_unknown_document_schema_properties.rs index b1b9b87b373..ce145576bf2 100644 --- a/packages/rs-drive/src/drive/contract/migration/strip_unknown_document_schema_properties.rs +++ b/packages/rs-drive/src/drive/contract/migration/strip_unknown_document_schema_properties.rs @@ -222,3 +222,204 @@ impl Drive { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use dpp::identity::Identity; + use dpp::platform_value::Value; + use dpp::tests::json_document::json_document_to_contract_with_ids; + use dpp::version::PlatformVersion; + + const OWNER_BALANCE: u64 = 5_000_000; + + /// The protocol 12 schema migration rewrote every stored user contract + /// whose document schemas carried top-level properties the v1 document + /// meta-schema forbids. It kept each element's storage flags and applied + /// the rewrite with a discarded cost vector, so the storage fee share of + /// the stripped bytes was never refunded to the contract owner and no + /// pending epoch refund was recorded. That is what every node executed + /// at the protocol 12 activation, and replay from genesis must reproduce + /// it byte for byte: the stripped byte counts were never recorded, so a + /// retroactive settlement is impossible, and the migration never runs + /// again, so there is nothing at a later version left to correct. The + /// storage refund invariant that applies from protocol version 15 names + /// this migration as its recorded historical exception; this test pins + /// the shipped behaviour so the exception stays exactly what it was. + #[test] + fn should_keep_the_protocol_12_schema_strip_frozen_without_refunding_stripped_bytes() { + let platform_version = PlatformVersion::get(12).expect("protocol version 12"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let transaction = drive.grove.start_transaction(); + + // The contract owner exists with a balance, so a refund would be + // observable as a balance change. + let mut owner = + Identity::random_identity(2, Some(12), platform_version).expect("expected an identity"); + owner.set_balance(OWNER_BALANCE); + drive + .add_new_identity( + owner.clone(), + false, + &BlockInfo::default(), + true, + Some(&transaction), + platform_version, + ) + .expect("expected to insert the owner"); + drive + .add_to_system_credits(OWNER_BALANCE, Some(&transaction), platform_version) + .expect("expected to record the system credits"); + + // A user contract stored the way protocol version 12 stores it: the + // element carries the owner's storage flags. + let contract = json_document_to_contract_with_ids( + "tests/supporting_files/contract/family/family-contract.json", + None, + Some(owner.id()), + false, + platform_version, + ) + .expect("expected the family contract"); + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + Some(&transaction), + platform_version, + ) + .expect("expected to insert the contract"); + + let contract_id = contract.id(); + let contract_path = contract_root_path(contract_id.as_slice()); + let stored_element = drive + .grove_get_raw( + (&contract_path).into(), + &[0], + DirectQueryType::StatefulDirectQuery, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read the contract element") + .expect("expected the contract element to exist"); + let (clean_bytes, flags) = match stored_element { + Element::Item(bytes, flags) => (bytes, flags), + other => panic!("expected an item, got {:?}", other), + }; + let owner_flags = StorageFlags::from_element_flags_ref( + flags + .as_ref() + .expect("a user contract carries storage flags"), + ) + .expect("expected valid flags") + .expect("expected owner flags"); + assert_eq!( + owner_flags.owner_id(), + Some(&owner.id().to_buffer()), + "the contract bytes are attributed to the owner" + ); + + // Rewrite the stored bytes the way a pre-v12 contract could look: + // with a top-level document schema property the v1 meta-schema does + // not allow. The element keeps its flags and grows. + let bincode_config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + let (mut serialization_format, _): (DataContractInSerializationFormat, usize) = + bincode::borrow_decode_from_slice(&clean_bytes, bincode_config) + .expect("expected to decode the stored contract"); + let person_schema = serialization_format + .document_schemas_mut() + .get_mut("person") + .expect("expected the person document type"); + person_schema + .insert("legacyUnknownProperty".to_string(), Value::Bool(true)) + .expect("expected to add the unknown property"); + let inflated_bytes = bincode::encode_to_vec(&serialization_format, bincode_config) + .expect("expected to encode the inflated contract"); + assert!(inflated_bytes.len() > clean_bytes.len()); + drive + .grove_insert( + (&contract_path).into(), + &[0], + Element::Item(inflated_bytes.clone(), flags.clone()), + Some(&transaction), + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to store the inflated contract"); + drive.cache.data_contracts.clear(); + + let pending_refunds_before = drive + .fetch_pending_epoch_refunds(Some(&transaction), &platform_version.drive) + .expect("expected the pending refunds"); + assert!(pending_refunds_before.is_empty()); + + // The migration, exactly as the protocol 12 activation ran it. + drive + .strip_unknown_document_schema_properties(&transaction, &platform_version.drive) + .expect("expected the migration to succeed"); + + let migrated_element = drive + .grove_get_raw( + (&contract_path).into(), + &[0], + DirectQueryType::StatefulDirectQuery, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read the migrated element") + .expect("expected the migrated element to exist"); + let (migrated_bytes, migrated_flags) = match migrated_element { + Element::Item(bytes, flags) => (bytes, flags), + other => panic!("expected an item, got {:?}", other), + }; + assert!( + migrated_bytes.len() < inflated_bytes.len(), + "the migration strips the unknown property, so the element shrinks" + ); + assert_eq!( + migrated_bytes, clean_bytes, + "the migration restores the bytes the contract had without the property" + ); + assert_eq!( + migrated_flags, flags, + "the element keeps the owner's storage flags byte for byte" + ); + + // The stripped bytes were owner-paid, yet nothing was refunded: the + // owner's balance and the pending epoch refunds are untouched and the + // credit sum stays balanced. + assert_eq!( + drive + .fetch_identity_balance( + owner.id().to_buffer(), + Some(&transaction), + platform_version + ) + .expect("expected the owner's balance"), + Some(OWNER_BALANCE) + ); + assert!(drive + .fetch_pending_epoch_refunds(Some(&transaction), &platform_version.drive) + .expect("expected the pending refunds") + .is_empty()); + let total = drive + .calculate_total_credits_balance(Some(&transaction), &platform_version.drive) + .expect("expected the total credits balance"); + assert!( + total.ok().expect("expected a well-formed balance"), + "no credits move during the migration: {:?}", + total + ); + } +} From 14536f5c012f036ebac9a6c51393ee78045ad6ab Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:58:45 -0500 Subject: [PATCH 07/20] docs(book): describe fee history and refund ownership from protocol version 15 Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- book/src/fees/overview.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 6dc8a66b8d5..b64839e0159 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -450,6 +450,34 @@ out to proposers. There is a **dust limit**: refunds below 32 bytes worth of storage credits are discarded to prevent micro-refund spam. +### Fee history and refund ownership (protocol version 15 onward) + +A refund is priced with the fee history of the block that removes the bytes: +the `previous_fee_versions` map platform state carries, which the epoch change +hook extends whenever the fee version number changes. `Drive::calculate_fee` +v1 (`DRIVE_VERSION_V10`) consults that history for every owner-attributed +storage removal, on every fee version number, and returns an internal error +when a caller passes none. Earlier generations priced fee version number 1 +against an empty history, so a caller that forgot the history silently +refunded at the first generation's storage rates; from protocol version 15 +that omission halts instead of mispricing. Every shipped schedule shares fee +version number 1 and the same storage rates, so the credits themselves are +unchanged for every shipped input. + +Refunds follow the recorded owner in the element's storage flags. +`Drive::credit_storage_refunds_to_owners_operations` credits each owner that +has a balance element without consulting any key or permission, so a frozen +but existing owner still receives its bookkeeping refund, and reports the +amount whose owner has no balance element (the native stand-in for a wiped +owner) for the caller to move into the current epoch's processing pool with a +single pool write. The caller records every refund against its storage epoch +in the pending epoch refunds, so the credit conservation check stays balanced. +Block lifecycle paths that remove owner-attributed bytes settle their refunds +this way in the block that removes them. The protocol 12 schema migration, +which shrank stored contracts without refunding the stripped bytes, ran once +at that activation and is the recorded historical exception; it replays +exactly as executed. + ## Epoch-Based Fee Distribution Fees do not go directly to the block proposer. Instead, they accumulate in @@ -523,6 +551,15 @@ 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). +`fee_version_number` keys the persisted fee history that refunds are priced +against. A schedule that changes storage rates needs a new number, because the +refund code resolves the schedule for an epoch through the history and (in +generations before protocol version 15) shortcut number 1 to the first +generation's rates. `FEE_VERSION1` and `FEE_VERSION2` share number 1 because +only a non-storage group changed between them; a test in `rs-drive`'s fee +operation module pins that every shipped schedule keeps the first generation's +storage rates. + ## Key Source Files | File | Contents | From ec0dcb70bc3355548455c3f2d519e09150822b19 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 05:26:09 -0500 Subject: [PATCH 08/20] test(drive): pass the fee history where tests replace epoch-flagged documents Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../insert_contract/v0/tests/ranked_index_e2e_tests.rs | 9 ++++++++- packages/rs-drive/src/drive/document/update/mod.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs index 1a1112d1a89..6313f97fc58 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs @@ -67,12 +67,15 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::platform_value::Value; use dpp::prelude::DataContract; use dpp::tests::json_document::json_document_to_contract; +use dpp::version::fee::FeeVersion; use dpp::version::PlatformVersion; use grovedb::element::indexed::AVG_FIXED_POINT_SCALE; use grovedb::Element; +use std::collections::BTreeMap; /// The one index property every doctype in the fixture ranks by. const GROUP_PROPERTY: &str = "restaurantId"; @@ -1331,6 +1334,10 @@ fn estimated_and_actual_update_fees( .document_type_for_name(document_type_name) .unwrap_or_else(|_| panic!("{document_type_name} doctype exists")); let storage_flags = Some(Cow::Owned(StorageFlags::SingleEpoch(0))); + // The replaced element carries epoch flags, so pricing its removal needs + // the fee history of the removing block, as every production caller + // passes. + let fee_history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); let run = |apply: bool| { drive @@ -1344,7 +1351,7 @@ fn estimated_and_actual_update_fees( storage_flags.clone(), None, pv, - None, + Some(&fee_history), ) .unwrap_or_else(|e| { panic!("expected the {document_type_name} update (apply={apply}) to succeed: {e}") diff --git a/packages/rs-drive/src/drive/document/update/mod.rs b/packages/rs-drive/src/drive/document/update/mod.rs index 5f4addfe4d6..ba62c8413f5 100644 --- a/packages/rs-drive/src/drive/document/update/mod.rs +++ b/packages/rs-drive/src/drive/document/update/mod.rs @@ -3476,7 +3476,7 @@ mod tests { storage_flags, None, platform_version, - None, + Some(&EPOCH_CHANGE_FEE_VERSION_TEST), ) .expect("key-changing update on aggregate index must succeed"); From b314c8637b1b3f1d9ab2ddd2951955cb9c74d1d1 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 05:30:05 -0500 Subject: [PATCH 09/20] test(drive): name the per-owner refund fixtures for clippy Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../v0/mod.rs | 5 ++++- packages/rs-drive/src/fees/op.rs | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs index 514249ede1d..81d51e05add 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs @@ -97,6 +97,9 @@ mod tests { const IDENTITY_BALANCE: Credits = 10_000_000; const PROCESSING_POOL_SEED: Credits = 1_000_000; + /// Refund credits per storage epoch, per owner. + type RefundsByOwner<'a> = [([u8; 32], &'a [(u16, Credits)])]; + fn insert_identity( drive: &Drive, seed: u64, @@ -119,7 +122,7 @@ mod tests { identity } - fn refunds(entries: &[([u8; 32], &[(u16, Credits)])]) -> FeeRefunds { + fn refunds(entries: &RefundsByOwner) -> FeeRefunds { let mut fee_refunds = FeeRefunds::default(); for (owner, credits_per_epoch) in entries { let mut epochs = CreditsPerEpoch::default(); diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index 00b4f1844e9..90d6ad103a1 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -2906,6 +2906,9 @@ mod tests { const OWNER: [u8; 32] = [7; 32]; const OTHER_OWNER: [u8; 32] = [9; 32]; + /// Removed bytes per storage epoch, per owner. + type BytesByOwner<'a> = [([u8; 32], &'a [(u16, u32)])]; + /// A schedule no protocol version references, with doubled storage /// rates, so a test can tell "the history was consulted" apart from /// "the first generation's rates were used". @@ -2922,9 +2925,7 @@ mod tests { /// One removed element whose bytes are attributed per owner and per /// storage epoch, the shape grovedb reports for an element carrying /// owner storage flags. - fn sectioned_removal( - bytes_by_owner: &[([u8; 32], &[(u16, u32)])], - ) -> LowLevelDriveOperation { + fn sectioned_removal(bytes_by_owner: &BytesByOwner) -> LowLevelDriveOperation { let mut removal = StorageRemovalPerEpochByIdentifier::default(); for (owner, bytes_per_epoch) in bytes_by_owner { let owner_bytes = removal.entry(*owner).or_default(); @@ -3118,7 +3119,7 @@ mod tests { // equal v0's: the boundary changes what a missing history does, // not what a present one yields. let removal_epoch = 15u16; - let bytes_by_owner: &[([u8; 32], &[(u16, u32)])] = &[ + let bytes_by_owner: &BytesByOwner = &[ (OWNER, &[(0, 900), (3, 1200), (7, 64), (12, 5000)]), (OTHER_OWNER, &[(5, 31), (11, 2048)]), (Identifier::default(), &[(2, 700)]), From 237d35b1230ff510ba4f4861170511bb6c90eb8c Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 15:49:49 -0500 Subject: [PATCH 10/20] fix(drive): report refund credits that repay identity debt for the processing pool add_to_identity_balance_operations clears negative credit before raising a zero balance, and that share never reaches the credit sum trees. The refund primitive now measures it, reports it as repaid_debt beside the unrouted amount, and exposes processing_pool_share() for the caller's single pool write. Tests cover refunds below, equal to and above the outstanding debt with the conservation check, and a positive balance that repays nothing. Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../mod.rs | 21 +- .../v0/mod.rs | 279 +++++++++++++++++- .../storage_refund_credit_outcome/mod.rs | 29 +- 3 files changed, 310 insertions(+), 19 deletions(-) diff --git a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs index 1e107fe2692..2230977b31b 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs @@ -17,12 +17,17 @@ impl Drive { /// per-epoch credits are summed with checked arithmetic. An owner with a /// balance element is credited through `add_to_identity_balance_operations`; /// no key, signature or permission is consulted, so a frozen but existing - /// owner receives its bookkeeping refund. An owner without a balance - /// element (the native proxy for a wiped owner) is not credited and its - /// amount is reported as `routed_to_processing_pool`, for the caller to - /// settle into the current epoch's processing pool with one pool write. - /// The primitive records no pending refunds; the caller does, so the block - /// keeps a single pending-refund and pool write per batch. + /// owner receives its bookkeeping refund. When that owner's balance is + /// zero the helper first clears its negative credit (identity debt), and + /// only the remainder reaches the balance; the cleared debt is reported as + /// `repaid_debt` because debt lives outside the credit sum trees and is + /// processing fee the pools were short of when it was incurred. An owner + /// without a balance element (the native proxy for a wiped owner) is not + /// credited and its amount is reported as `routed_to_processing_pool`. + /// The caller settles `processing_pool_share()` (both amounts) into the + /// current epoch's processing pool with one pool write and records the + /// pending refunds; the primitive does neither, so the block keeps a + /// single pending-refund and pool write per batch. /// /// # Parameters /// @@ -37,8 +42,8 @@ impl Drive { /// /// # Returns /// - /// * `Ok(StorageRefundCreditOutcome)` - The owners credited and the amount - /// routed to the processing pool. + /// * `Ok(StorageRefundCreditOutcome)` - The owners credited, the debt the + /// refunds repaid and the amount routed to the processing pool. /// * `Err(Error)` - On overflow, a corrupted balance element, or when the /// method is not active for the platform version. pub fn credit_storage_refunds_to_owners_operations( diff --git a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs index 81d51e05add..2236dd79346 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs @@ -1,5 +1,6 @@ use crate::drive::identity::update::storage_refund_credit_outcome::StorageRefundCreditOutcome; use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use dpp::fee::fee_result::refunds::FeeRefunds; @@ -24,6 +25,7 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result { let mut credited: BTreeMap = BTreeMap::new(); + let mut repaid_debt: Credits = 0; let mut routed_to_processing_pool: Credits = 0; for (owner_id, credits_per_epoch) in fee_refunds.iter() { @@ -52,7 +54,27 @@ impl Drive { platform_version, )?; - if existing_balance.is_some() { + if let Some(existing_balance) = existing_balance { + // `add_to_identity_balance_operations` clears negative credit before + // raising a zero balance. That portion never reaches the sum trees, + // so it is measured here for the caller's processing pool write. + let owner_repaid_debt = if existing_balance == 0 { + let debt = self + .fetch_identity_negative_balance_operations( + *owner_id, + true, + transaction, + drive_operations, + platform_version, + )? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "an identity with a balance element always has a negative credit element", + )))?; + debt.min(credits) + } else { + 0 + }; + let mut estimated_costs_only_with_layer_info = None::>; @@ -64,7 +86,17 @@ impl Drive { platform_version, )?); - credited.insert(Identifier::from(*owner_id), credits); + repaid_debt = + repaid_debt + .checked_add(owner_repaid_debt) + .ok_or(ProtocolError::Overflow( + "storage refund credits repaying identity debt overflow", + ))?; + + let reached_balance = credits - owner_repaid_debt; + if reached_balance > 0 { + credited.insert(Identifier::from(*owner_id), reached_balance); + } } else { routed_to_processing_pool = routed_to_processing_pool.checked_add(credits).ok_or( ProtocolError::Overflow( @@ -76,6 +108,7 @@ impl Drive { Ok(StorageRefundCreditOutcome { credited, + repaid_debt, routed_to_processing_pool, }) } @@ -84,7 +117,6 @@ impl Drive { #[cfg(test)] mod tests { use super::*; - use crate::error::drive::DriveError; use crate::util::batch::DriveOperation; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; @@ -122,6 +154,150 @@ mod tests { identity } + /// An identity with a zero balance and `debt` of negative credit, the + /// state an identity is left in after paying a processing fee it could + /// not fully cover. + fn insert_identity_with_debt( + drive: &Drive, + seed: u64, + debt: Credits, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Identity { + let mut identity = Identity::random_identity(3, Some(seed), platform_version) + .expect("expected a random identity"); + identity.set_balance(0); + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + Some(transaction), + platform_version, + ) + .expect("expected to insert the identity"); + apply( + drive, + vec![ + drive.update_identity_negative_credit_operation_v0(identity.id().to_buffer(), debt) + ], + transaction, + platform_version, + ); + identity + } + + fn debt( + drive: &Drive, + identity_id: [u8; 32], + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Option { + drive + .fetch_identity_negative_balance_operations( + identity_id, + true, + Some(transaction), + &mut vec![], + platform_version, + ) + .expect("expected to fetch the negative credit") + } + + /// Settles a refund for an identity with a zero balance and `debt` of + /// negative credit exactly as a lifecycle caller would (credits, one pool + /// write for the pool share, pending refunds recorded) and returns the + /// outcome, the identity's balance and debt afterwards, and whether the + /// credit sum is balanced. + fn settle_refund_against_debt( + debt_amount: Credits, + refund: Credits, + ) -> ( + StorageRefundCreditOutcome, + Option, + Option, + bool, + ) { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + let epoch = Epoch::new(0).expect("epoch 0"); + + let owner = + insert_identity_with_debt(&drive, 8, debt_amount, &transaction, platform_version); + let owner_id = owner.id().to_buffer(); + + let seed_operation = drive + .add_epoch_processing_credits_for_distribution_operation( + &epoch, + PROCESSING_POOL_SEED, + Some(&transaction), + platform_version, + ) + .expect("expected the pool seed operation"); + apply(&drive, vec![seed_operation], &transaction, platform_version); + drive + .add_to_system_credits(PROCESSING_POOL_SEED, Some(&transaction), platform_version) + .expect("expected to record the system credits"); + + let fee_refunds = refunds(&[(owner_id, &[(0, refund)])]); + let mut operations = vec![]; + let outcome = drive + .credit_storage_refunds_to_owners_operations( + &fee_refunds, + None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("expected to settle the refund"); + operations.push( + drive + .add_epoch_processing_credits_for_distribution_operation( + &epoch, + outcome + .processing_pool_share() + .expect("expected the pool share"), + Some(&transaction), + platform_version, + ) + .expect("expected the pool write"), + ); + apply(&drive, operations, &transaction, platform_version); + + let mut pending_refund_operations: Vec = vec![]; + Drive::add_update_pending_epoch_refunds_operations( + &mut pending_refund_operations, + fee_refunds.sum_per_epoch(), + &platform_version.drive, + ) + .expect("expected the pending refund operations"); + drive + .apply_drive_operations( + pending_refund_operations, + true, + &BlockInfo::default(), + Some(&transaction), + platform_version, + None, + ) + .expect("expected to record the pending refunds"); + + let balanced = drive + .calculate_total_credits_balance(Some(&transaction), &platform_version.drive) + .expect("expected the balance") + .ok() + .expect("expected a well-formed balance"); + + ( + outcome, + balance(&drive, owner_id, &transaction, platform_version), + debt(&drive, owner_id, &transaction, platform_version), + balanced, + ) + } + fn refunds(entries: &RefundsByOwner) -> FeeRefunds { let mut fee_refunds = FeeRefunds::default(); for (owner, credits_per_epoch) in entries { @@ -194,6 +370,7 @@ mod tests { outcome, StorageRefundCreditOutcome { credited: BTreeMap::from([(first.id(), 1_001), (second.id(), 5_000)]), + repaid_debt: 0, routed_to_processing_pool: 0, } ); @@ -242,6 +419,7 @@ mod tests { outcome, StorageRefundCreditOutcome { credited: BTreeMap::from([(existing.id(), 400)]), + repaid_debt: 0, routed_to_processing_pool: 300, } ); @@ -399,7 +577,9 @@ mod tests { drive .add_epoch_processing_credits_for_distribution_operation( &epoch, - outcome.routed_to_processing_pool, + outcome + .processing_pool_share() + .expect("expected the pool share"), Some(&transaction), platform_version, ) @@ -455,6 +635,97 @@ mod tests { ); } + #[test] + fn should_report_a_refund_below_the_owners_debt_as_repaid_debt() { + let (outcome, balance, debt, balanced) = settle_refund_against_debt(100, 60); + + assert_eq!( + outcome, + StorageRefundCreditOutcome { + credited: BTreeMap::new(), + repaid_debt: 60, + routed_to_processing_pool: 0, + }, + "nothing reaches the balance; the whole refund clears debt" + ); + assert_eq!(balance, Some(0)); + assert_eq!(debt, Some(40)); + assert!(balanced, "the repaid debt went to the processing pool"); + } + + #[test] + fn should_report_a_refund_equal_to_the_owners_debt_as_repaid_debt() { + let (outcome, balance, debt, balanced) = settle_refund_against_debt(100, 100); + + assert_eq!( + outcome, + StorageRefundCreditOutcome { + credited: BTreeMap::new(), + repaid_debt: 100, + routed_to_processing_pool: 0, + } + ); + assert_eq!(balance, Some(0)); + assert_eq!(debt, Some(0)); + assert!(balanced); + } + + #[test] + fn should_split_a_refund_above_the_owners_debt_between_debt_and_balance() { + let (outcome, balance, debt, balanced) = settle_refund_against_debt(100, 150); + + let owner = Identity::random_identity(3, Some(8), PlatformVersion::latest()) + .expect("expected the same random identity") + .id(); + assert_eq!( + outcome, + StorageRefundCreditOutcome { + credited: BTreeMap::from([(owner, 50)]), + repaid_debt: 100, + routed_to_processing_pool: 0, + }, + "only the remainder above the debt is reported as credited" + ); + assert_eq!(outcome.processing_pool_share(), Some(100)); + assert_eq!(outcome.total(), Some(150)); + assert_eq!(balance, Some(50)); + assert_eq!(debt, Some(0)); + assert!(balanced); + } + + #[test] + fn should_not_touch_debt_when_the_owner_has_a_positive_balance() { + // The shipped helper repays debt only from a zero balance; an owner + // that is in debt yet holds a balance cannot exist through the fee + // path, but the primitive must still report what the helper does. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let owner = insert_identity(&drive, 9, &transaction, platform_version); + let owner_id = owner.id().to_buffer(); + + let fee_refunds = refunds(&[(owner_id, &[(0, 250)])]); + let mut operations = vec![]; + let outcome = drive + .credit_storage_refunds_to_owners_operations( + &fee_refunds, + None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("expected to credit the owner"); + apply(&drive, operations, &transaction, platform_version); + + assert_eq!(outcome.repaid_debt, 0); + assert_eq!(outcome.credited, BTreeMap::from([(owner.id(), 250)])); + assert_eq!( + balance(&drive, owner_id, &transaction, platform_version), + Some(IDENTITY_BALANCE + 250) + ); + } + #[test] fn should_not_be_active_before_the_new_drive_table() { let drive = setup_drive_with_initial_state_structure(None); diff --git a/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs b/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs index 2e42e9bd7b2..b80f1d38cab 100644 --- a/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs @@ -3,18 +3,26 @@ use dpp::prelude::Identifier; use std::collections::BTreeMap; /// What `Drive::credit_storage_refunds_to_owners_operations` did with a set of -/// storage refunds: which recorded owners were credited and how much could not -/// be routed to any owner. +/// storage refunds: which recorded owners were credited, how much of the +/// refunds cleared identity debt instead of reaching a balance, and how much +/// could not be routed to any owner. /// -/// The caller that knows the block's epoch settles `routed_to_processing_pool` +/// The caller that knows the block's epoch settles `processing_pool_share()` /// into that epoch's processing pool with a single pool write and records the /// refunds against their storage epochs; the primitive itself never touches /// the pools. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct StorageRefundCreditOutcome { - /// Credits added to each recorded owner's balance, summed over the epochs - /// the owner's bytes were stored in. + /// Credits that reached each recorded owner's balance element, summed + /// over the epochs the owner's bytes were stored in. An owner whose whole + /// refund went into clearing debt has no entry. pub credited: BTreeMap, + /// Credits that cleared an owner's negative credit (identity debt) instead + /// of raising its balance. Debt is processing fee the pools were short of + /// when it was incurred and it lives outside the credit sum trees, so the + /// caller moves this amount into the current epoch's processing pool to + /// keep the credit sum balanced. + pub repaid_debt: Credits, /// Credits whose recorded owner has no balance element. Until a typed /// owner encoding exists this is the native proxy for a wiped owner, so /// the caller moves this amount into the current epoch's processing pool. @@ -22,11 +30,18 @@ pub struct StorageRefundCreditOutcome { } impl StorageRefundCreditOutcome { - /// The total credited to owners plus the amount routed to the pool. + /// What the caller writes into the current epoch's processing pool: the + /// unrouted refunds plus the debt they repaid. + pub fn processing_pool_share(&self) -> Option { + self.routed_to_processing_pool.checked_add(self.repaid_debt) + } + + /// The total settled: credited to balances, repaid as debt, or routed to + /// the pool. pub fn total(&self) -> Option { self.credited .values() - .try_fold(self.routed_to_processing_pool, |total, credits| { + .try_fold(self.processing_pool_share()?, |total, credits| { total.checked_add(*credits) }) } From 7cfb3565c5271fe20b483cb19fa66bbc8eebf3f9 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 15:49:49 -0500 Subject: [PATCH 11/20] docs(book): say lifecycle refund settlement is not yet wired at protocol version 15 Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- book/src/fees/overview.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index b64839e0159..6073d37f947 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -467,16 +467,20 @@ unchanged for every shipped input. Refunds follow the recorded owner in the element's storage flags. `Drive::credit_storage_refunds_to_owners_operations` credits each owner that has a balance element without consulting any key or permission, so a frozen -but existing owner still receives its bookkeeping refund, and reports the -amount whose owner has no balance element (the native stand-in for a wiped -owner) for the caller to move into the current epoch's processing pool with a -single pool write. The caller records every refund against its storage epoch -in the pending epoch refunds, so the credit conservation check stays balanced. -Block lifecycle paths that remove owner-attributed bytes settle their refunds -this way in the block that removes them. The protocol 12 schema migration, -which shrank stored contracts without refunding the stripped bytes, ran once -at that activation and is the recorded historical exception; it replays -exactly as executed. +but existing owner still receives its bookkeeping refund. Two shares of a +refund never reach a balance and are reported for the caller instead: the +part that clears an owner's negative credit (identity debt, which lives +outside the credit sum trees) and the part whose owner has no balance element +(the native stand-in for a wiped owner). The caller moves both into the +current epoch's processing pool with a single pool write and records every +refund against its storage epoch in the pending epoch refunds, so the credit +conservation check stays balanced. This primitive is the settlement step +block lifecycle paths that remove owner-attributed bytes are meant to use in +the block that removes them; at protocol version 15 the vote poll end cleanup +does not yet price or settle its refunds, and wiring it up is a separate +change. The protocol 12 schema migration, which shrank stored contracts +without refunding the stripped bytes, ran once at that activation and is the +recorded historical exception; it replays exactly as executed. ## Epoch-Based Fee Distribution From 02cd70c6d045152979f973820e7c1ec0f3745604 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 16:52:20 -0500 Subject: [PATCH 12/20] docs(book): attribute storage refunds to the recorded owner in the fee overview Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- book/src/fees/overview.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 6073d37f947..6d2e2469c6b 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -49,8 +49,9 @@ in `FeeStorageVersion`: | `storage_seek_cost` | 2,000 | Cost of a single disk seek | Storage fees are **refundable**: when data is deleted, a portion of the original -storage fee is returned to the identity that paid it (see [Refunds](#refunds) -below). +storage fee becomes a refund for the owner recorded in the stored bytes' storage +flags, which is not always the identity that paid the fee (see +[Refunds](#refunds) below). ### Processing Fees From 884cb10dccc2f6759e8ff829059ca1bc8bd94f13 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 18:53:36 -0500 Subject: [PATCH 13/20] refactor(drive): read each refund owner once when crediting its balance The balance read that decides whether the owner exists now feeds add_to_previous_balance directly, followed by the balance and negative credit update operations, the same shape the payer's own refund uses. The shipped helper reads the negative credit only from a zero balance, so an owner costs one or two stateful reads instead of up to four, and the repaid debt is derived from the helper's outcome instead of a separate read. A test pins the read count per owner. Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../mod.rs | 8 +- .../v0/mod.rs | 113 +++++++++++++----- 2 files changed, 87 insertions(+), 34 deletions(-) diff --git a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs index 2230977b31b..6fe378b6e46 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs @@ -15,9 +15,11 @@ impl Drive { /// /// For every owner in `fee_refunds` except `skip_owner`, the owner's /// per-epoch credits are summed with checked arithmetic. An owner with a - /// balance element is credited through `add_to_identity_balance_operations`; - /// no key, signature or permission is consulted, so a frozen but existing - /// owner receives its bookkeeping refund. When that owner's balance is + /// balance element is credited the way a state transition payer's own + /// refund is (one balance read feeding `add_to_previous_balance`, then the + /// balance and negative credit updates); no key, signature or permission + /// is consulted, so a frozen but existing owner receives its bookkeeping + /// refund. When that owner's balance is /// zero the helper first clears its negative credit (identity debt), and /// only the remainder reaches the balance; the cleared debt is reported as /// `repaid_debt` because debt lives outside the credit sum trees and is diff --git a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs index 2236dd79346..435d1edc580 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs @@ -1,6 +1,6 @@ +use crate::drive::identity::update::add_to_previous_balance_outcome::AddToPreviousBalanceOutcomeV0Methods; use crate::drive::identity::update::storage_refund_credit_outcome::StorageRefundCreditOutcome; use crate::drive::Drive; -use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use dpp::fee::fee_result::refunds::FeeRefunds; @@ -8,9 +8,8 @@ use dpp::fee::Credits; use dpp::prelude::Identifier; use dpp::version::PlatformVersion; use dpp::ProtocolError; -use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, TransactionArg}; -use std::collections::{BTreeMap, HashMap}; +use grovedb::TransactionArg; +use std::collections::BTreeMap; impl Drive { /// Credits each recorded refund owner that has a balance element and @@ -55,36 +54,48 @@ impl Drive { )?; if let Some(existing_balance) = existing_balance { - // `add_to_identity_balance_operations` clears negative credit before - // raising a zero balance. That portion never reaches the sum trees, - // so it is measured here for the caller's processing pool write. - let owner_repaid_debt = if existing_balance == 0 { - let debt = self - .fetch_identity_negative_balance_operations( - *owner_id, - true, - transaction, - drive_operations, - platform_version, - )? - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "an identity with a balance element always has a negative credit element", - )))?; - debt.min(credits) - } else { - 0 - }; - - let mut estimated_costs_only_with_layer_info = - None::>; - - drive_operations.extend(self.add_to_identity_balance_operations( + // The same shape as the payer's own credit in + // `apply_balance_change_from_fee_to_identity`: the balance read above + // feeds the shipped helper directly, which reads the negative credit + // itself only when the balance is zero, so nothing is read twice. + let outcome = self.add_to_previous_balance( *owner_id, + existing_balance, credits, - &mut estimated_costs_only_with_layer_info, + true, transaction, + drive_operations, platform_version, - )?); + )?; + + if let Some(new_balance) = outcome.balance_modified() { + drive_operations + .push(self.update_identity_balance_operation_v0(*owner_id, new_balance)?); + } + + if let Some(new_negative_balance) = outcome.negative_credit_balance_modified() { + drive_operations.push(self.update_identity_negative_credit_operation_v0( + *owner_id, + new_negative_balance, + )); + } + + // From a zero balance the helper clears negative credit first and only + // the remainder becomes the new balance; from a positive balance the + // whole refund is added. Whatever did not reach the balance repaid + // debt, which lives outside the sum trees and is reported for the + // caller's processing pool write. + let reached_balance = if existing_balance == 0 { + outcome.balance_modified().unwrap_or(0) + } else { + credits + }; + let owner_repaid_debt = + credits + .checked_sub(reached_balance) + .ok_or(ProtocolError::Overflow( + "a storage refund cannot raise a balance by more than the refund", + ))?; repaid_debt = repaid_debt @@ -93,7 +104,6 @@ impl Drive { "storage refund credits repaying identity debt overflow", ))?; - let reached_balance = credits - owner_repaid_debt; if reached_balance > 0 { credited.insert(Identifier::from(*owner_id), reached_balance); } @@ -117,6 +127,7 @@ impl Drive { #[cfg(test)] mod tests { use super::*; + use crate::error::drive::DriveError; use crate::util::batch::DriveOperation; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; @@ -726,6 +737,46 @@ mod tests { ); } + #[test] + fn should_read_each_owner_once_before_writing_its_balance() { + // The balance read decides whether the owner exists and then feeds the + // shipped helper, which reads the negative credit only from a zero + // balance: two stateful reads for an owner in debt, one otherwise. + // A second read of either element would show up as an extra cost + // operation and be billed to every refund the caller settles. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let funded = insert_identity(&drive, 10, &transaction, platform_version); + let in_debt = insert_identity_with_debt(&drive, 11, 100, &transaction, platform_version); + + let count_reads = |owner_id: [u8; 32]| { + let mut operations = vec![]; + drive + .credit_storage_refunds_to_owners_operations( + &refunds(&[(owner_id, &[(0, 150)])]), + None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("expected to credit the owner"); + operations + .iter() + .filter(|operation| { + matches!( + operation, + LowLevelDriveOperation::CalculatedCostOperation(_) + ) + }) + .count() + }; + + assert_eq!(count_reads(funded.id().to_buffer()), 1); + assert_eq!(count_reads(in_debt.id().to_buffer()), 2); + } + #[test] fn should_not_be_active_before_the_new_drive_table() { let drive = setup_drive_with_initial_state_structure(None); From e39b0a461c14d1247c4667e0beb8a31324540b65 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 22 Sep 2026 13:17:47 -0500 Subject: [PATCH 14/20] test(drive): unban, unsuspend and replace suspensions through the production funnel with fee history The contract moderation tests that landed on the base after this branch was cut remove or shrink moderator-flagged entries through the bare fee-returning wrappers, which pass no fee history. From protocol version 15 pricing such a removal without the history is an error, so those calls now go through apply_drive_operations with a history, the funnel production uses; calls that only insert keep using the wrappers. Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../src/drive/contract/moderation/tests.rs | 328 ++++++++++++------ 1 file changed, 215 insertions(+), 113 deletions(-) diff --git a/packages/rs-drive/src/drive/contract/moderation/tests.rs b/packages/rs-drive/src/drive/contract/moderation/tests.rs index 611a942210b..326bdee33cc 100644 --- a/packages/rs-drive/src/drive/contract/moderation/tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/tests.rs @@ -6,6 +6,9 @@ use crate::drive::contract::paths::{ CONTRACT_VERSION_KEY, CONTRACT_WARNINGS_KEY, }; use crate::drive::{Drive, RootTree}; +use crate::error::Error; +use crate::util::batch::drive_op_batch::ContractModerationOperationType; +use crate::util::batch::DriveOperation; use crate::util::grove_operations::DirectQueryType; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; @@ -17,10 +20,14 @@ use dpp::data_contract::config::moderation::{ ContractWarning, }; use dpp::data_contract::DataContract; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::tests::fixtures::get_data_contract_fixture; +use dpp::version::fee::FeeVersion; use dpp::version::PlatformVersion; use grovedb::Element; +use std::collections::BTreeMap; fn reason(text: &str) -> ContractModerationReason { ContractModerationReason::from_text(text) @@ -90,6 +97,101 @@ fn insert(drive: &Drive, contract: &DataContract, platform_version: &PlatformVer .expect("expected to insert the contract"); } +/// Removing an entry, or replacing one with a shorter reason, frees bytes flagged with the +/// moderator that paid for them, and pricing that removal needs the fee history of the +/// removing block. Production applies moderation through `apply_drive_operations`, which +/// forwards the block's history; the tests below take the same funnel for those calls. +fn fee_history() -> CachedEpochIndexFeeVersions { + BTreeMap::from([(0, FeeVersion::first())]) +} + +fn apply_moderation( + drive: &Drive, + operation: ContractModerationOperationType, + block_info: &BlockInfo, + apply: bool, + platform_version: &PlatformVersion, +) -> Result { + let history = fee_history(); + drive.apply_drive_operations( + vec![DriveOperation::ContractModerationOperation(operation)], + apply, + block_info, + None, + platform_version, + Some(&history), + ) +} + +fn unban( + drive: &Drive, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + apply: bool, + platform_version: &PlatformVersion, +) -> Result { + apply_moderation( + drive, + ContractModerationOperationType::RemoveBan { + contract_id, + identity_id, + }, + block_info, + apply, + platform_version, + ) +} + +fn unsuspend( + drive: &Drive, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + apply: bool, + platform_version: &PlatformVersion, +) -> Result { + apply_moderation( + drive, + ContractModerationOperationType::RemoveSuspension { + contract_id, + identity_id, + }, + block_info, + apply, + platform_version, + ) +} + +/// Replaces the identity's existing suspension entry. +#[allow(clippy::too_many_arguments)] +fn resuspend( + drive: &Drive, + contract_id: Identifier, + identity_id: Identifier, + until: u64, + reason: &ContractModerationReason, + moderator_id: Identifier, + block_info: &BlockInfo, + apply: bool, + platform_version: &PlatformVersion, +) -> Result { + apply_moderation( + drive, + ContractModerationOperationType::AddSuspension { + contract_id, + identity_id, + until, + reason: reason.clone(), + replaces_existing: true, + moderator_id, + }, + block_info, + apply, + platform_version, + ) +} + fn has_list_tree(drive: &Drive, contract_id: Identifier, key: u8) -> bool { drive .grove_has_raw( @@ -326,16 +428,15 @@ fn should_ban_and_unban_and_prove_the_status_and_the_entries() { }], ); - let fee = drive - .remove_contract_ban( - contract_id, - target, - &BlockInfo::default(), - true, - None, - platform_version, - ) - .expect("expected to unban"); + let fee = unban( + &drive, + contract_id, + target, + &BlockInfo::default(), + true, + platform_version, + ) + .expect("expected to unban"); assert!( fee.fee_refunds .calculate_refunds_amount_for_identity(moderator) @@ -433,16 +534,15 @@ fn should_suspend_replace_and_unsuspend() { }], ); - drive - .remove_contract_suspension( - contract_id, - target, - &BlockInfo::default(), - true, - None, - platform_version, - ) - .expect("expected to unsuspend"); + unsuspend( + &drive, + contract_id, + target, + &BlockInfo::default(), + true, + platform_version, + ) + .expect("expected to unsuspend"); assert_status( &drive, contract_id, @@ -523,48 +623,44 @@ fn should_estimate_before_applying_every_writer() { "suspension storage" ); - let estimated = drive - .remove_contract_suspension( - contract_id, - target, - &block_info, - false, - None, - platform_version, - ) - .expect("expected to estimate an unsuspend"); + let estimated = unsuspend( + &drive, + contract_id, + target, + &block_info, + false, + platform_version, + ) + .expect("expected to estimate an unsuspend"); assert!(estimated.processing_fee > 0); - drive - .remove_contract_suspension( - contract_id, - target, - &block_info, - true, - None, - platform_version, - ) - .expect("expected to unsuspend"); - let estimated = drive - .remove_contract_ban( - contract_id, - target, - &block_info, - false, - None, - platform_version, - ) - .expect("expected to estimate an unban"); + unsuspend( + &drive, + contract_id, + target, + &block_info, + true, + platform_version, + ) + .expect("expected to unsuspend"); + let estimated = unban( + &drive, + contract_id, + target, + &block_info, + false, + platform_version, + ) + .expect("expected to estimate an unban"); assert!(estimated.processing_fee > 0); - drive - .remove_contract_ban( - contract_id, - target, - &block_info, - true, - None, - platform_version, - ) - .expect("expected to unban"); + unban( + &drive, + contract_id, + target, + &block_info, + true, + platform_version, + ) + .expect("expected to unban"); assert_status( &drive, contract_id, @@ -678,24 +774,21 @@ fn should_refund_the_first_moderator_when_another_replaces_the_suspension() { // Another moderator, a later epoch, a reason of the same length. The entry keeps its size, // so the replacement stores nothing new, and the storage stays the first moderator's. let later = BlockInfo::default_with_epoch(Epoch::new(3).expect("epoch 3")); - let fee = drive - .add_contract_suspension( - contract_id, - target, - 20, - &reason("flooding"), - true, - second_moderator, - &later, - true, - None, - platform_version, - ) - .expect("expected to replace the suspension"); + let fee = resuspend( + &drive, + contract_id, + target, + 20, + &reason("flooding"), + second_moderator, + &later, + true, + platform_version, + ) + .expect("expected to replace the suspension"); assert_eq!(fee.storage_fee, 0, "a same-size replacement stores nothing"); - let fee = drive - .remove_contract_suspension(contract_id, target, &later, true, None, platform_version) + let fee = unsuspend(&drive, contract_id, target, &later, true, platform_version) .expect("expected to unsuspend"); assert!( fee.fee_refunds @@ -742,20 +835,18 @@ fn should_bill_the_replacing_moderator_for_a_longer_reason() { // the replacement does not fail on the two owners. let later = BlockInfo::default_with_epoch(Epoch::new(3).expect("epoch 3")); let longer = reason(&"flooding ".repeat(40)); - let second_fee = drive - .add_contract_suspension( - contract_id, - target, - 20, - &longer, - true, - second_moderator, - &later, - true, - None, - platform_version, - ) - .expect("expected to replace the suspension with a longer reason"); + let second_fee = resuspend( + &drive, + contract_id, + target, + 20, + &longer, + second_moderator, + &later, + true, + platform_version, + ) + .expect("expected to replace the suspension with a longer reason"); assert!(second_fee.storage_fee > 0, "the added bytes are stored"); assert!( second_fee.storage_fee > first_fee.storage_fee, @@ -777,8 +868,7 @@ fn should_bill_the_replacing_moderator_for_a_longer_reason() { ); // The entry, and the refund of its removal, passed to the moderator that replaced it. - let fee = drive - .remove_contract_suspension(contract_id, target, &later, true, None, platform_version) + let fee = unsuspend(&drive, contract_id, target, &later, true, platform_version) .expect("expected to unsuspend"); assert!( fee.fee_refunds @@ -823,20 +913,18 @@ fn should_keep_a_shorter_replacement_with_the_first_moderator() { // Another moderator, a later epoch, a shorter reason: nothing is added, the removed bytes // go back to the moderator that paid for them, and the entry stays that moderator's. let later = BlockInfo::default_with_epoch(Epoch::new(3).expect("epoch 3")); - let fee = drive - .add_contract_suspension( - contract_id, - target, - 20, - &reason("flooding"), - true, - second_moderator, - &later, - true, - None, - platform_version, - ) - .expect("expected to replace the suspension with a shorter reason"); + let fee = resuspend( + &drive, + contract_id, + target, + 20, + &reason("flooding"), + second_moderator, + &later, + true, + platform_version, + ) + .expect("expected to replace the suspension with a shorter reason"); assert_eq!(fee.storage_fee, 0, "a shorter replacement stores nothing"); assert!( fee.fee_refunds @@ -856,8 +944,7 @@ fn should_keep_a_shorter_replacement_with_the_first_moderator() { suspended_until(20, "flooding"), ); - let fee = drive - .remove_contract_suspension(contract_id, target, &later, true, None, platform_version) + let fee = unsuspend(&drive, contract_id, target, &later, true, platform_version) .expect("expected to unsuspend"); assert!( fee.fee_refunds @@ -888,20 +975,35 @@ fn should_not_estimate_a_replacement_below_what_it_costs() { let moderator = contract.owner_id(); let target = identity(0x54); let suspend = |until: u64, length: usize, replaces_existing: bool, apply: bool| { - drive - .add_contract_suspension( + if replaces_existing { + resuspend( + &drive, contract_id, target, until, &reason(&"x".repeat(length)), - replaces_existing, moderator, &BlockInfo::default(), apply, - None, platform_version, ) - .expect("expected to suspend") + .expect("expected to replace the suspension") + } else { + drive + .add_contract_suspension( + contract_id, + target, + until, + &reason(&"x".repeat(length)), + false, + moderator, + &BlockInfo::default(), + apply, + None, + platform_version, + ) + .expect("expected to suspend") + } }; suspend(10, from, false, true); From 1de5ca40e0457dbc52ce21607a0d8cedbe1f8bd1 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 22 Sep 2026 13:45:05 -0500 Subject: [PATCH 15/20] fix(drive): price ephemeral TTL bytes in calculate_fee v1 as v0 does consume_to_fees_v1 was written before the time-range TTL work added the ephemeral cost arm to v0 and the rebase kept v1 without it, so a TTL'd index write was billed to storage at protocol version 15. v1 now carries the same arm: added bytes bill to processing at the ephemeral rate, storage stays zero, removal is basic and needs no fee history, a sectioned removal is corrupted state. A test pins v1 equal to v0 on ephemeral operations. The TTL twin test's delete of owner-flagged standing index bytes now passes a fee history. Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../time_range_index_e2e_tests.rs | 8 +- packages/rs-drive/src/fees/op.rs | 131 +++++++++++++++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs index 5deaf230552..0fd12a4dbf6 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs @@ -27,9 +27,11 @@ use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::DataContractFactory; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::FeeResult; use dpp::platform_value::{platform_value, Identifier, Value}; use dpp::prelude::DataContract; +use dpp::version::fee::FeeVersion; use dpp::version::PlatformVersion; use std::borrow::Cow; use std::collections::BTreeMap; @@ -4236,6 +4238,10 @@ fn ttl_index_bytes_bill_to_processing_without_refunds() { ); let doc_id = make_doc().id(); + // The standing twin's index bytes carry owner flags, so pricing their + // removal needs the fee history of the removing block, as every + // production caller passes. + let fee_history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); let delete = |contract: &DataContract| -> FeeResult { drive .delete_document_for_contract( @@ -4246,7 +4252,7 @@ fn ttl_index_bytes_bill_to_processing_without_refunds() { true, None, platform_version, - None, + Some(&fee_history), ) .expect("delete document") }; diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index 90d6ad103a1..62be629fd12 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -373,7 +373,8 @@ impl LowLevelDriveOperation { /// that removes the bytes. /// /// This is the generation `Drive::calculate_fee` v1 selects. It differs - /// from `consume_to_fees_v0` in one arm: a `SectionedStorageRemoval` + /// from `consume_to_fees_v0` in one arm (the ephemeral TTL'd-subtree + /// arm is the same): a `SectionedStorageRemoval` /// always consults `previous_fee_versions` and returns /// `CorruptedCodeExecution` when none is given, on every fee version /// number. v0 priced fee version number 1 against an empty history, so a @@ -395,6 +396,45 @@ impl LowLevelDriveOperation { processing_fee: op.cost(fee_version), ..Default::default() }), + CalculatedEphemeralCostOperation(cost) => { + // TTL'd-subtree bytes: the added bytes bill to + // PROCESSING at the ephemeral rate instead of to + // storage, exactly as in v0. TTL elements carry no + // storage flags, so their removal can only ever be + // basic and needs no fee history; a sectioned removal + // here is a corrupted batch, not a missing history. + let ephemeral_bytes_fee = (cost.storage_cost.added_bytes as u64) + .checked_mul( + fee_version + .storage + .ttl_ephemeral_disk_usage_credit_per_byte, + ) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow pricing ephemeral bytes", + )))?; + let processing_fee = cost + .ephemeral_cost(fee_version)? + .checked_add(ephemeral_bytes_fee) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow adding ephemeral bytes fee", + )))?; + let removed_bytes_from_system = match cost.storage_cost.removed_bytes { + NoStorageRemoval => 0, + BasicStorageRemoval(amount) => amount, + SectionedStorageRemoval(_) => { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "TTL'd subtrees carry no storage flags, so an ephemeral \ + batch cannot produce sectioned (refundable) removal", + ))) + } + }; + Ok(FeeResult { + storage_fee: 0, + processing_fee, + fee_refunds: FeeRefunds::default(), + removed_bytes_from_system, + }) + } _ => { let cost = operation.operation_cost()?; // There is no need for a checked multiply here because added bytes are u64 and @@ -3168,6 +3208,95 @@ mod tests { } } + #[test] + fn should_price_ephemeral_bytes_to_processing_without_fee_history_in_v1() { + // TTL'd index bytes bill to processing at the ephemeral rate and + // their removal is never sectioned, so the arm is the same in both + // generations and needs no history even under the strict rule. + let ephemeral = |added: u32, removed: u32| { + LowLevelDriveOperation::CalculatedEphemeralCostOperation(OperationCost { + seek_count: 2, + storage_cost: StorageCost { + added_bytes: added, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::BasicStorageRemoval(removed), + }, + storage_loaded_bytes: 10, + hash_node_calls: 1, + sinsemilla_hash_calls: 0, + }) + }; + + let v0_results = LowLevelDriveOperation::consume_to_fees_v0( + vec![ephemeral(500, 40)], + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ) + .expect("v0 prices ephemeral bytes"); + let v1_results = LowLevelDriveOperation::consume_to_fees_v1( + vec![ephemeral(500, 40)], + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ) + .expect("v1 prices ephemeral bytes without a history"); + + assert_eq!(v0_results, v1_results); + assert_eq!( + v1_results[0].storage_fee, 0, + "TTL'd bytes never bill storage" + ); + assert_eq!( + v1_results[0].processing_fee, + ephemeral(500, 40) + .operation_cost() + .expect("cost") + .ephemeral_cost(&FEE_VERSION1) + .expect("ephemeral cost") + + 500 + * FEE_VERSION1 + .storage + .ttl_ephemeral_disk_usage_credit_per_byte + ); + assert_eq!(v1_results[0].removed_bytes_from_system, 40); + assert_eq!(v1_results[0].fee_refunds, FeeRefunds::default()); + + // A sectioned removal inside an ephemeral batch is corrupted state in + // both generations, with or without a history. + let corrupted = || { + let mut removal = StorageRemovalPerEpochByIdentifier::default(); + removal.entry(OWNER).or_default().insert(3, 100); + LowLevelDriveOperation::CalculatedEphemeralCostOperation(OperationCost { + seek_count: 0, + storage_cost: StorageCost { + added_bytes: 0, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::SectionedStorageRemoval(removal), + }, + storage_loaded_bytes: 0, + hash_node_calls: 0, + sinsemilla_hash_calls: 0, + }) + }; + let history: CachedEpochIndexFeeVersions = + BTreeMap::from([(0u16, FeeVersion::first())]); + for previous_fee_versions in [None, Some(&history)] { + assert!(matches!( + LowLevelDriveOperation::consume_to_fees_v1( + vec![corrupted()], + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + previous_fee_versions, + ), + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + )); + } + } + /// The premise of the equality above. A shipped schedule that kept /// fee version number 1 but changed its storage rates would make v0 /// (which prices number 1 at the first generation) and v1 (which From 124938b6d66c3341283e4189eedde34e795993c6 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 22 Sep 2026 13:45:05 -0500 Subject: [PATCH 16/20] test(drive): close the structure fixture's group action with fee history and label fixtures with the latest version Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- packages/rs-drive/grovedb-structure.json | 70 ++++++++++++------------ packages/rs-drive/src/structure/tests.rs | 39 +++++++++---- 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 343665de9b8..16521adbf8f 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "latest_protocol_version": 14, + "latest_protocol_version": 15, "element_kinds": [ { "name": "Item", @@ -5096,7 +5096,7 @@ }, "layer_shapes": { "contract_groups": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "00", "right": { @@ -5105,7 +5105,7 @@ } }, "contract_groups.groups.group": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "02", "left": { @@ -5120,7 +5120,7 @@ } }, "contract_groups.members.contract": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "01", "left": { @@ -5132,7 +5132,7 @@ } }, "contracts.contract": { - "origin": "fixture contracts_with_documents@14", + "origin": "fixture contracts_with_documents@15", "tree": { "hex": "01", "left": { @@ -5144,7 +5144,7 @@ } }, "contracts.contract.other": { - "origin": "fixture moderated_contract@14", + "origin": "fixture moderated_contract@15", "tree": { "hex": "80", "left": { @@ -5162,7 +5162,7 @@ } }, "group_actions.contract.group": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "4d", "left": { @@ -5174,7 +5174,7 @@ } }, "group_actions.contract.group.active.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "53", "left": { @@ -5183,7 +5183,7 @@ } }, "group_actions.contract.group.closed.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "53", "left": { @@ -5192,7 +5192,7 @@ } }, "identities.identity": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5217,7 +5217,7 @@ "states": [ { "state": "created", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5236,7 +5236,7 @@ }, { "state": "used_with_a_contract", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5258,7 +5258,7 @@ }, { "state": "budgeted_key_and_contract", - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5284,7 +5284,7 @@ ] }, "identities.identity.contract_info.bound": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "01", "left": { @@ -5293,7 +5293,7 @@ } }, "identities.identity.key_references": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "03", "left": { @@ -5305,7 +5305,7 @@ } }, "identities.identity.key_references.authentication": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "02", "left": { @@ -5320,7 +5320,7 @@ } }, "misc": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "54", "left": { @@ -5329,7 +5329,7 @@ } }, "pools.epoch": { - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5357,14 +5357,14 @@ "states": [ { "state": "future", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "73" } }, { "state": "running", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5392,7 +5392,7 @@ }, { "state": "paid", - "origin": "fixture paid_epoch@14", + "origin": "fixture paid_epoch@15", "tree": { "hex": "74", "left": { @@ -5415,7 +5415,7 @@ ] }, "prefunded_balances": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5427,7 +5427,7 @@ } }, "root": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "40", "left": { @@ -5484,7 +5484,7 @@ } }, "saved_block_transactions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "65", "left": { @@ -5496,7 +5496,7 @@ } }, "shielded_balances.main_pool": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5514,7 +5514,7 @@ } }, "tokens": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5535,7 +5535,7 @@ } }, "tokens.distributions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5550,7 +5550,7 @@ } }, "tokens.distributions.perpetual.token": { - "origin": "fixture token_distributions_unclaimed@14", + "origin": "fixture token_distributions_unclaimed@15", "tree": { "hex": "c0", "left": { @@ -5559,7 +5559,7 @@ } }, "tokens.distributions.timed": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5571,7 +5571,7 @@ } }, "versions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "01", "left": { @@ -5580,7 +5580,7 @@ } }, "votes": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "64", "left": { @@ -5592,7 +5592,7 @@ } }, "votes.contested_resource": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "70", "left": { @@ -5601,7 +5601,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5610,7 +5610,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type.indexes.value.contender": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5619,7 +5619,7 @@ } }, "withdrawals": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "03", "left": { diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 6a1889e289c..8ece29320e2 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -119,7 +119,13 @@ fn should_record_a_contract_layer_with_its_documents_on_top() { // fixture. Documents are read most and sit at the root of the layer; the // contract itself and everything else hang below. let contract = &json["layer_shapes"]["contracts.contract"]; - assert_eq!(contract["origin"], "fixture contracts_with_documents@14"); + assert_eq!( + contract["origin"], + format!( + "fixture contracts_with_documents@{}", + PlatformVersion::latest().protocol_version + ) + ); assert_eq!(contract["tree"]["hex"], "01"); assert_eq!(contract["tree"]["left"]["hex"], "00"); assert_eq!(contract["tree"]["right"]["hex"], "02"); @@ -279,7 +285,7 @@ mod fixtures { use crate::structure::shape::shape_at; use crate::structure::{KeySpec, NodeId}; use crate::util::batch::drive_op_batch::{ - AddressFundsOperationType, ContractFeePotOperationType, + AddressFundsOperationType, ContractFeePotOperationType, GroupOperationType, }; use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; use crate::util::batch::ContractModerationOperationType; @@ -297,6 +303,8 @@ mod fixtures { use crate::drive::credit_pools::epochs::paths::EpochProposers; use dpp::block::epoch::Epoch; use dpp::block::finalized_epoch_info::v0::FinalizedEpochInfoV0; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::version::fee::FeeVersion; use dpp::contract_group::{ generate_contract_group_id, ContractGroupMember, ContractGroupMembership, ContractGroupRegistration, @@ -1041,19 +1049,30 @@ mod fixtures { ) .expect("expected to propose the action"); if close { + // Closing moves signer-flagged items, so pricing the removal + // needs the fee history of the removing block; production + // closes actions through `apply_drive_operations`, which + // forwards it. + let fee_history: CachedEpochIndexFeeVersions = + BTreeMap::from([(0, FeeVersion::first())]); drive - .add_group_action( - contract.id(), - 0, - None, + .apply_drive_operations( + vec![DriveOperation::GroupOperation( + GroupOperationType::AddGroupAction { + contract_id: contract.id(), + group_contract_position: 0, + initialize_with_insert_action_info: None, + action_id, + signer_identity_id: member_2, + signer_power: 2, + closes_group_action: true, + }, + )], true, - action_id, - member_2, - 2, &BlockInfo::default(), - true, None, platform_version, + Some(&fee_history), ) .expect("expected to close the action"); } From 5e700d98627ec216997e52a6f9fa8eaa91109a7e Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Wed, 23 Sep 2026 01:12:13 -0500 Subject: [PATCH 17/20] fix(drive)!: price a batch before committing the transaction drive owns From protocol version 15 pricing an owner-attributed storage removal without the fee history is an error. The fee-returning entry points that start their own transaction when the caller passes none committed it before pricing, so that error could come back after the write had been persisted. New generations hold the owned transaction until Drive::calculate_fee succeeded and drop it with everything it wrote on an error: apply_drive_operations v2 (finalize tasks still run after the commit), add_group_action v1, and the three moderation writers that can free moderator-flagged bytes (remove_contract_ban, remove_contract_suspension, add_contract_suspension) v1. Drive table v10 selects them through DRIVE_GROUP_METHOD_VERSIONS_V2 and DRIVE_CONTRACT_METHOD_VERSIONS_V5. With a caller transaction nothing changes; shipped generations are byte-identical. Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../moderation/add_contract_suspension/mod.rs | 29 +- .../add_contract_suspension/v1/mod.rs | 163 ++++++++ .../moderation/remove_contract_ban/mod.rs | 21 +- .../moderation/remove_contract_ban/v1/mod.rs | 135 +++++++ .../remove_contract_suspension/mod.rs | 21 +- .../remove_contract_suspension/v1/mod.rs | 135 +++++++ .../remove_contract_warnings/mod.rs | 21 +- .../remove_contract_warnings/v1/mod.rs | 135 +++++++ .../group/insert/add_group_action/mod.rs | 32 +- .../group/insert/add_group_action/v1/mod.rs | 124 ++++++ .../apply_drive_operations/mod.rs | 11 +- .../apply_drive_operations/v1/mod.rs | 2 +- .../apply_drive_operations/v2/mod.rs | 378 ++++++++++++++++++ .../drive_contract_method_versions/mod.rs | 1 + .../drive_contract_method_versions/v5.rs | 26 ++ .../drive_group_method_versions/mod.rs | 1 + .../drive_group_method_versions/v2.rs | 40 ++ .../src/version/drive_versions/v10.rs | 18 +- .../rs-platform-version/src/version/v15.rs | 5 + 19 files changed, 1281 insertions(+), 17 deletions(-) create mode 100644 packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/group/insert/add_group_action/v1/mod.rs create mode 100644 packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs create mode 100644 packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs create mode 100644 packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/v2.rs diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/mod.rs index 27edb72855a..94d9d50fc9e 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -72,9 +73,21 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_contract_suspension_v1( + contract_id, + identity_id, + until, + reason, + replaces_existing, + moderator_id, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contract_suspension".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -136,9 +149,21 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_contract_suspension_operations_v1( + contract_id, + identity_id, + until, + reason, + replaces_existing, + moderator_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contract_suspension_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/v1/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/v1/mod.rs new file mode 100644 index 00000000000..ad24e2e7297 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_suspension/v1/mod.rs @@ -0,0 +1,163 @@ +use crate::drive::contract::moderation::types::encode_suspension; +use crate::drive::contract::paths::contract_moderation_list_path; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::PathKeyElementInfo::PathFixedSizeKeyRefElement; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::config::moderation::{ContractModerationList, ContractModerationReason}; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::identity::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::Element; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from generation 0 in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Generation 0 applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, this wrapper + /// persisted the write and then failed. It still passes no fee history, so a call that + /// frees moderator-flagged bytes still fails at protocol version 15; it now fails before + /// anything is written. Production applies moderation through `apply_drive_operations`, + /// which forwards the block's history. With a caller transaction nothing is committed + /// by Drive in either generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_contract_suspension_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + until: TimestampMillis, + reason: &ContractModerationReason, + replaces_existing: bool, + moderator_id: Identifier, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let batch_operations = self.add_contract_suspension_operations_v1( + contract_id, + identity_id, + until, + reason, + replaces_existing, + moderator_id, + block_info, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + Ok(fees) + } + + /// A suspension is `until` as eight big-endian bytes, then its reason, under the identity's + /// id, flagged with the moderator's identity so the storage refund on removal goes back to + /// whoever paid. An existing entry is replaced in place; two operations on one key would + /// fail the batch. The replacement brings its own reason, so the entry may change size, and + /// its flags follow GroveDB's flag merge: a longer entry passes, with the refund of its + /// removal, to the moderator that replaced it, who pays for the added bytes; a shorter or + /// an equally long one stays the first moderator's, who is refunded the removed bytes. + /// + /// An estimate prices the replacement as a fresh insert. GroveDB's average-case replace + /// assumes an item keeps its size and would price no storage for a longer reason, which + /// the moderator's balance is then not checked against; the whole entry is an upper bound. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_contract_suspension_operations_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + until: TimestampMillis, + reason: &ContractModerationReason, + replaces_existing: bool, + moderator_id: Identifier, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + _transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let estimating = estimated_costs_only_with_layer_info.is_some(); + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Drive::add_estimation_costs_for_contract_moderation_entry( + contract_id.to_buffer(), + ContractModerationList::Suspensions, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + let storage_flags = + StorageFlags::new_single_epoch(block_info.epoch.index, Some(moderator_id.to_buffer())); + + let path_key_element = PathFixedSizeKeyRefElement(( + contract_moderation_list_path( + contract_id.as_slice(), + ContractModerationList::Suspensions, + ), + identity_id.as_slice(), + Element::new_item_with_flags( + encode_suspension(until, reason), + storage_flags.to_some_element_flags(), + ), + )); + + let mut batch_operations: Vec = vec![]; + if replaces_existing && !estimating { + self.batch_replace( + path_key_element, + &mut batch_operations, + &platform_version.drive, + )?; + } else { + self.batch_insert( + path_key_element, + &mut batch_operations, + &platform_version.drive, + )?; + } + + Ok(batch_operations) + } +} diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/mod.rs index 635e59da960..1be47a4d9bc 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -57,9 +58,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.remove_contract_ban_v1( + contract_id, + identity_id, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_contract_ban".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -108,9 +117,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.remove_contract_ban_operations_v1( + contract_id, + identity_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_contract_ban_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/v1/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/v1/mod.rs new file mode 100644 index 00000000000..d08f1749d6d --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_ban/v1/mod.rs @@ -0,0 +1,135 @@ +use crate::drive::contract::moderation::types::estimated_entry_value_size; +use crate::drive::contract::paths::contract_moderation_list_path; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use crate::util::storage_flags::StorageFlags; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::config::moderation::ContractModerationList; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::{MaybeTree, TreeType}; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from generation 0 in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Generation 0 applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, this wrapper + /// persisted the write and then failed. It still passes no fee history, so a call that + /// frees moderator-flagged bytes still fails at protocol version 15; it now fails before + /// anything is written. Production applies moderation through `apply_drive_operations`, + /// which forwards the block's history. With a caller transaction nothing is committed + /// by Drive in either generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_contract_ban_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let batch_operations = self.remove_contract_ban_operations_v1( + contract_id, + identity_id, + block_info, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + Ok(fees) + } + + /// Deletes the entry. The storage refund follows the entry's own flags, which name the + /// moderator that wrote it. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_contract_ban_operations_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + _block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let list = ContractModerationList::Banlist; + + let apply_type = if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Drive::add_estimation_costs_for_contract_moderation_entry( + contract_id.to_buffer(), + list, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::NormalTree, + estimated_key_size: DEFAULT_HASH_SIZE_U32, + estimated_value_size: estimated_entry_value_size(list) + + StorageFlags::approximate_size(true, None), + } + } else { + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + }; + + let mut batch_operations: Vec = vec![]; + self.batch_delete( + (&contract_moderation_list_path(contract_id.as_slice(), list)).into(), + identity_id.as_slice(), + apply_type, + transaction, + &mut batch_operations, + &platform_version.drive, + )?; + + Ok(batch_operations) + } +} diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/mod.rs index b9de9495b19..2dceb635a71 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -57,9 +58,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.remove_contract_suspension_v1( + contract_id, + identity_id, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_contract_suspension".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -108,9 +117,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.remove_contract_suspension_operations_v1( + contract_id, + identity_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_contract_suspension_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/v1/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/v1/mod.rs new file mode 100644 index 00000000000..adfc90582c2 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_suspension/v1/mod.rs @@ -0,0 +1,135 @@ +use crate::drive::contract::moderation::types::estimated_entry_value_size; +use crate::drive::contract::paths::contract_moderation_list_path; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use crate::util::storage_flags::StorageFlags; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::config::moderation::ContractModerationList; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::{MaybeTree, TreeType}; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from generation 0 in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Generation 0 applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, this wrapper + /// persisted the write and then failed. It still passes no fee history, so a call that + /// frees moderator-flagged bytes still fails at protocol version 15; it now fails before + /// anything is written. Production applies moderation through `apply_drive_operations`, + /// which forwards the block's history. With a caller transaction nothing is committed + /// by Drive in either generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_contract_suspension_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let batch_operations = self.remove_contract_suspension_operations_v1( + contract_id, + identity_id, + block_info, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + Ok(fees) + } + + /// Deletes the entry. The storage refund follows the entry's own flags, which name the + /// moderator that wrote it. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_contract_suspension_operations_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + _block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let list = ContractModerationList::Suspensions; + + let apply_type = if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Drive::add_estimation_costs_for_contract_moderation_entry( + contract_id.to_buffer(), + list, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::NormalTree, + estimated_key_size: DEFAULT_HASH_SIZE_U32, + estimated_value_size: estimated_entry_value_size(list) + + StorageFlags::approximate_size(true, None), + } + } else { + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + }; + + let mut batch_operations: Vec = vec![]; + self.batch_delete( + (&contract_moderation_list_path(contract_id.as_slice(), list)).into(), + identity_id.as_slice(), + apply_type, + transaction, + &mut batch_operations, + &platform_version.drive, + )?; + + Ok(batch_operations) + } +} diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs index 6886bf14d62..e09f3662f83 100644 --- a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -57,9 +58,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.remove_contract_warnings_v1( + contract_id, + identity_id, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_contract_warnings".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -108,9 +117,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.remove_contract_warnings_operations_v1( + contract_id, + identity_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_contract_warnings_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v1/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v1/mod.rs new file mode 100644 index 00000000000..5bd2ec6bf8b --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v1/mod.rs @@ -0,0 +1,135 @@ +use crate::drive::contract::moderation::types::estimated_entry_value_size; +use crate::drive::contract::paths::contract_moderation_list_path; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use crate::util::storage_flags::StorageFlags; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::config::moderation::ContractModerationList; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::{MaybeTree, TreeType}; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from generation 0 in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Generation 0 applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, this wrapper + /// persisted the write and then failed. It still passes no fee history, so a call that + /// frees moderator-flagged bytes still fails at protocol version 15; it now fails before + /// anything is written. Production applies moderation through `apply_drive_operations`, + /// which forwards the block's history. With a caller transaction nothing is committed + /// by Drive in either generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_contract_warnings_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let batch_operations = self.remove_contract_warnings_operations_v1( + contract_id, + identity_id, + block_info, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + Ok(fees) + } + + /// Deletes the entry. The storage refund follows the entry's own flags, which name the + /// moderator that wrote it. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_contract_warnings_operations_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + _block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let list = ContractModerationList::Warnings; + + let apply_type = if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Drive::add_estimation_costs_for_contract_moderation_entry( + contract_id.to_buffer(), + list, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::NormalTree, + estimated_key_size: DEFAULT_HASH_SIZE_U32, + estimated_value_size: estimated_entry_value_size(list) + + StorageFlags::approximate_size(true, None), + } + } else { + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + }; + + let mut batch_operations: Vec = vec![]; + self.batch_delete( + (&contract_moderation_list_path(contract_id.as_slice(), list)).into(), + identity_id.as_slice(), + apply_type, + transaction, + &mut batch_operations, + &platform_version.drive, + )?; + + Ok(batch_operations) + } +} diff --git a/packages/rs-drive/src/drive/group/insert/add_group_action/mod.rs b/packages/rs-drive/src/drive/group/insert/add_group_action/mod.rs index 37642fc5bf3..d317493bc81 100644 --- a/packages/rs-drive/src/drive/group/insert/add_group_action/mod.rs +++ b/packages/rs-drive/src/drive/group/insert/add_group_action/mod.rs @@ -15,6 +15,7 @@ use grovedb::{EstimatedLayerInformation, TransactionArg}; use std::collections::HashMap; mod v0; +mod v1; impl Drive { /// Adds an action to the state @@ -47,9 +48,22 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_group_action_v1( + contract_id, + group_contract_position, + initialize_with_insert_action_info, + closes_group_action, + action_id, + signer_identity_id, + signer_power, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_group_action".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -87,9 +101,23 @@ impl Drive { drive_operations, platform_version, ), + 1 => self.add_group_action_add_to_operations_v1( + contract_id, + group_contract_position, + initialize_with_insert_action_info, + closes_group_action, + action_id, + signer_identity_id, + signer_power, + block_info, + apply, + transaction, + drive_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_group_action_add_to_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/group/insert/add_group_action/v1/mod.rs b/packages/rs-drive/src/drive/group/insert/add_group_action/v1/mod.rs new file mode 100644 index 00000000000..7daaf877733 --- /dev/null +++ b/packages/rs-drive/src/drive/group/insert/add_group_action/v1/mod.rs @@ -0,0 +1,124 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::group::GroupMemberPower; +use dpp::data_contract::GroupContractPosition; +use dpp::fee::fee_result::FeeResult; +use dpp::group::group_action::GroupAction; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Adds an action to the state and prices it, committing nothing until the price is + /// known. + /// + /// Generation 1 differs from generation 0 in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Generation 0 applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, closing an + /// action through this wrapper persisted the closure and then failed. The wrapper still + /// passes no fee history, so such a close still fails at protocol version 15; it now + /// fails before anything is written. Production closes actions through + /// `apply_drive_operations`, which forwards the block's history. With a caller + /// transaction nothing is committed by Drive in either generation. + #[allow(clippy::too_many_arguments)] + pub(super) fn add_group_action_v1( + &self, + contract_id: Identifier, + group_contract_position: GroupContractPosition, + initialize_with_insert_action_info: Option, + closes_group_action: bool, + action_id: Identifier, + signer_identity_id: Identifier, + signer_power: GroupMemberPower, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + + let mut drive_operations: Vec = vec![]; + self.add_group_action_add_to_operations_v1( + contract_id, + group_contract_position, + initialize_with_insert_action_info, + closes_group_action, + action_id, + signer_identity_id, + signer_power, + block_info, + apply, + transaction, + &mut drive_operations, + platform_version, + )?; + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + Ok(fees) + } + + #[allow(clippy::too_many_arguments)] + /// Adds group creation operations to drive operations + pub(super) fn add_group_action_add_to_operations_v1( + &self, + contract_id: Identifier, + group_contract_position: GroupContractPosition, + initialize_with_insert_action_info: Option, + closes_group_action: bool, + action_id: Identifier, + signer_identity_id: Identifier, + signer_power: GroupMemberPower, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let batch_operations = self.add_group_action_operations( + contract_id, + group_contract_position, + initialize_with_insert_action_info, + closes_group_action, + action_id, + signer_identity_id, + signer_power, + block_info, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + drive_operations, + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs index 27b98d4a88c..f90f2131499 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::util::batch::DriveOperation; @@ -64,9 +65,17 @@ impl Drive { platform_version, previous_fee_versions, ), + 2 => self.apply_drive_operations_v2( + operations, + apply, + block_info, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "apply_drive_operations".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs index 72993a46739..5dceda04dda 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs @@ -144,7 +144,7 @@ impl Drive { /// for them. The whole batch, which is exact: a moderator's deletion removes the document and /// nothing else, since its removal record is written once (a document id is produced at most /// once) and the nonce it bumps keeps its size. -fn forfeit_storage_refunds(cost_operations: &mut [LowLevelDriveOperation]) { +pub(super) fn forfeit_storage_refunds(cost_operations: &mut [LowLevelDriveOperation]) { for operation in cost_operations.iter_mut() { let LowLevelDriveOperation::CalculatedCostOperation(cost) = operation else { continue; diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs new file mode 100644 index 00000000000..776609fe052 --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs @@ -0,0 +1,378 @@ +use crate::util::batch::DriveOperation; + +use crate::drive::Drive; +use crate::error::Error; + +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; + +use grovedb::{EstimatedLayerInformation, TransactionArg}; + +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; + +use crate::util::batch::drive_op_batch::drive_methods::apply_drive_operations::v1::forfeit_storage_refunds; +use crate::util::batch::drive_op_batch::finalize_task::{ + DriveOperationFinalizationTasks, DriveOperationFinalizeTask, +}; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use std::collections::HashMap; + +impl Drive { + /// Applies a list of high level DriveOperations to the drive, and calculates the fee for them. + /// + /// # Arguments + /// + /// * `operations` - A vector of `DriveOperation`s to apply to the drive. + /// * `apply` - A boolean flag indicating whether to apply the changes or only estimate costs. + /// * `block_info` - A reference to information about the current block. + /// * `transaction` - Transaction arguments. + /// + /// # Returns + /// + /// Returns a `Result` containing the `FeeResult` if the operations are successfully applied, + /// otherwise an `Error`. + /// + /// If `apply` is set to true, it applies the low-level drive operations and updates side info accordingly. + /// If not, it only estimates the costs and updates estimated costs with layer info. + /// + /// Generation 1 (protocol version 14) is generation 0, and a batch that carries a storage + /// refund forfeiture ([`DriveOperation::forfeits_storage_refunds`], a moderator's document + /// deletion) refunds nobody: the bytes it removes still leave the system, but whoever paid + /// for them gets nothing back, and the credits stay in the storage pools they were + /// distributed to. An estimate carries no refund to begin with, so `check_tx` sees the + /// same fee with or without the forfeiture. + /// + /// Generation 2 (protocol version 15) is generation 1 with the owned transaction held + /// until the batch is priced. From protocol version 15 pricing an owner-attributed storage + /// removal without the fee history is an error; generation 1 committed its owned + /// transaction before pricing, so a caller passing no transaction and no history had its + /// writes persisted and the error returned. Now `Drive::calculate_fee` runs first, a + /// pricing error drops the owned transaction with everything it wrote, and the finalize + /// tasks still run after the commit. With a caller transaction nothing is committed by + /// Drive in either generation. + #[inline(always)] + pub(crate) fn apply_drive_operations_v2( + &self, + operations: Vec, + apply: bool, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + if operations.is_empty() { + return Ok(FeeResult::default()); + } + let forfeits_storage_refunds = operations + .iter() + .any(DriveOperation::forfeits_storage_refunds); + // With no caller transaction, TTL preparation (direct drainage + // writes), conversion reads, and the batch apply would each commit + // on their own, so a conversion error after preparation would leave + // drained buckets committed without the write. Span all of it with + // one owned transaction and commit only once the batch applied. + let caller_transaction = transaction; + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(caller_transaction); + if apply { + self.prepare_drive_operations_time_range_ttl( + &operations, + block_info, + transaction, + platform_version, + )?; + } + let mut low_level_operations = vec![]; + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let mut finalize_tasks: Vec = Vec::new(); + + for drive_op in operations { + if let Some(tasks) = drive_op.finalization_tasks(platform_version)? { + finalize_tasks.extend(tasks); + } + + low_level_operations.append( + &mut drive_op.into_low_level_drive_operations_after_ttl_drain( + self, + &mut estimated_costs_only_with_layer_info, + block_info, + transaction, + platform_version, + )?, + ); + } + + let mut cost_operations = vec![]; + + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + low_level_operations, + &mut cost_operations, + &platform_version.drive, + )?; + if forfeits_storage_refunds { + forfeit_storage_refunds(&mut cost_operations); + } + + // Price before committing: a pricing error drops the owned transaction with + // everything it wrote, so nothing is persisted without its fee result. + let fee_result = Drive::calculate_fee( + None, + Some(cost_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + // Execute drive operation callbacks after updating state. Nothing was written when + // only estimating, so there is nothing to finalize. The tasks read through the + // caller's transaction; an owned one was committed just above, and `caller_transaction` + // is `None` exactly then, so they read committed state. + if apply { + for task in finalize_tasks { + task.execute(self, caller_transaction, platform_version)?; + } + } + + Ok(fee_result) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::util::batch::drive_op_batch::GroupOperationType; + use crate::util::batch::DriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::group::v0::GroupV0; + use dpp::data_contract::group::Group; + use dpp::data_contract::v1::DataContractV1; + use dpp::data_contract::DataContract; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::group::action_event::GroupActionEvent; + use dpp::group::group_action::v0::GroupActionV0; + use dpp::group::group_action::GroupAction; + use dpp::group::group_action_status::GroupActionStatus; + use dpp::identifier::Identifier; + use dpp::tokens::token_event::TokenEvent; + use dpp::version::fee::FeeVersion; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + /// A contract with one two-member group and an action the first member opened, + /// so closing the action moves signer-flagged items: the one removal a batch can + /// carry without a document fixture. + fn drive_with_open_group_action( + platform_version: &PlatformVersion, + ) -> (Drive, Identifier, Identifier, Identifier) { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let member_1 = Identifier::from([1; 32]); + let member_2 = Identifier::from([2; 32]); + let contract = DataContract::V1(DataContractV1 { + id: Identifier::from([3; 32]), + version: 0, + owner_id: member_1, + document_types: Default::default(), + config: DataContractConfig::V0(DataContractConfigV0 { + can_be_deleted: false, + readonly: false, + keeps_history: false, + documents_keep_history_contract_default: false, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: None, + requires_identity_decryption_bounded_key: None, + }), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: BTreeMap::from([( + 0, + Group::V0(GroupV0 { + members: [(member_1, 1), (member_2, 2)].into(), + required_power: 3, + }), + )]), + tokens: BTreeMap::from([( + 0, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + )]), + keywords: Vec::new(), + description: None, + }); + let contract_id = contract.id(); + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the contract"); + let action_id = Identifier::from([4; 32]); + let action = GroupAction::V0(GroupActionV0 { + contract_id, + proposer_id: member_1, + token_contract_position: 0, + event: GroupActionEvent::TokenEvent(TokenEvent::Mint(100, member_1, None)), + }); + drive + .add_group_action( + contract_id, + 0, + Some(action), + false, + action_id, + member_1, + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to open the action"); + (drive, contract_id, member_2, action_id) + } + + fn close_action( + contract_id: Identifier, + member_2: Identifier, + action_id: Identifier, + ) -> Vec> { + vec![DriveOperation::GroupOperation( + GroupOperationType::AddGroupAction { + contract_id, + group_contract_position: 0, + initialize_with_insert_action_info: None, + action_id, + signer_identity_id: member_2, + signer_power: 2, + closes_group_action: true, + }, + )] + } + + fn is_closed( + drive: &Drive, + contract_id: Identifier, + action_id: Identifier, + platform_version: &PlatformVersion, + ) -> bool { + drive + .fetch_action_is_closed( + contract_id, + 0, + action_id, + true, + None, + &mut vec![], + platform_version, + ) + .expect("expected to check if the action is closed") + } + + #[test] + fn should_not_commit_an_owned_transaction_when_the_batch_cannot_be_priced() { + // No caller transaction and no fee history: closing the action frees + // signer-flagged bytes, which the latest generation refuses to price. The + // batch was applied inside an owned transaction that is dropped with the + // error, so the action is still open afterwards. + let platform_version = PlatformVersion::latest(); + let (drive, contract_id, member_2, action_id) = + drive_with_open_group_action(platform_version); + + let result = drive.apply_drive_operations( + close_action(contract_id, member_2, action_id), + true, + &BlockInfo::default(), + None, + platform_version, + None, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "pricing a flagged removal without the fee history must fail, got {:?}", + result + ); + assert!( + !is_closed(&drive, contract_id, action_id, platform_version), + "a batch that could not be priced must not be committed" + ); + + // The same batch with the block's history is priced and committed. + let history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); + let fee_result = drive + .apply_drive_operations( + close_action(contract_id, member_2, action_id), + true, + &BlockInfo::default(), + None, + platform_version, + Some(&history), + ) + .expect("expected to close the action with the fee history"); + assert!(fee_result.fee_refunds.get(&[1; 32]).is_some()); + assert!(is_closed(&drive, contract_id, action_id, platform_version)); + let closed_signers = drive + .fetch_action_signers( + contract_id, + 0, + GroupActionStatus::ActionClosed, + action_id, + None, + platform_version, + ) + .expect("expected the closed signers"); + assert_eq!(closed_signers.len(), 2); + } + + #[test] + fn should_price_and_commit_without_history_under_the_frozen_generation() { + // Protocol version 14 selects generation 1, which commits before pricing and + // prices fee version number 1 without a history: the close persists. + let platform_version = PlatformVersion::get(14).expect("protocol version 14"); + let (drive, contract_id, member_2, action_id) = + drive_with_open_group_action(platform_version); + + drive + .apply_drive_operations( + close_action(contract_id, member_2, action_id), + true, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("the frozen generation prices the shipped shortcut without a history"); + assert!(is_closed(&drive, contract_id, action_id, platform_version)); + } +} diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs index d6d2c930e18..4d04c936415 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs @@ -4,6 +4,7 @@ pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; #[derive(Clone, Debug, Default)] pub struct DriveContractMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs new file mode 100644 index 00000000000..7fbbaf09f28 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs @@ -0,0 +1,26 @@ +use crate::version::drive_versions::drive_contract_method_versions::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_contract_method_versions::{ + DriveContractMethodVersions, DriveContractModerationMethodVersions, +}; + +/// Drive contract methods for protocol version 15. +/// +/// Relative to [`super::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4`], the four moderation +/// writers that can free moderator-flagged bytes (`remove_contract_ban`, +/// `remove_contract_suspension`, `remove_contract_warnings`, and `add_contract_suspension`, +/// whose replacement of an existing entry may shrink it) are bumped to `1`: when the caller passes no transaction, +/// the fee-returning wrapper writes and prices inside one owned transaction and commits it +/// only once `Drive::calculate_fee` succeeded. Pricing an owner-attributed storage removal +/// without the fee history is an error from this version, and generation 0 committed the +/// write before that error surfaced. The operation builders are unchanged. +pub const DRIVE_CONTRACT_METHOD_VERSIONS_V5: DriveContractMethodVersions = + DriveContractMethodVersions { + moderation: DriveContractModerationMethodVersions { + remove_contract_ban: 1, + add_contract_suspension: 1, + remove_contract_suspension: 1, + remove_contract_warnings: 1, + ..DRIVE_CONTRACT_METHOD_VERSIONS_V4.moderation + }, + ..DRIVE_CONTRACT_METHOD_VERSIONS_V4 + }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs index 5494feaba84..6abc2f32676 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs @@ -1,6 +1,7 @@ use grovedb_version::version::FeatureVersion; pub mod v1; +pub mod v2; #[derive(Clone, Debug, Default)] pub struct DriveGroupMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/v2.rs new file mode 100644 index 00000000000..3cbd9457df4 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/v2.rs @@ -0,0 +1,40 @@ +use crate::version::drive_versions::drive_group_method_versions::{ + DriveGroupCostEstimationMethodVersions, DriveGroupFetchMethodVersions, + DriveGroupInsertMethodVersions, DriveGroupMethodVersions, DriveGroupProveMethodVersions, +}; + +/// Drive group methods for protocol version 15. +/// +/// Relative to [`super::v1::DRIVE_GROUP_METHOD_VERSIONS_V1`], `insert.add_group_action` is +/// bumped to `1`: when the caller passes no transaction, the fee-returning wrapper writes and +/// prices inside one owned transaction and commits it only once `Drive::calculate_fee` +/// succeeded. Pricing an owner-attributed storage removal without the fee history is an error +/// from this version, and generation 0 committed a closing action before that error surfaced. +/// The operation builders are unchanged. +pub const DRIVE_GROUP_METHOD_VERSIONS_V2: DriveGroupMethodVersions = DriveGroupMethodVersions { + fetch: DriveGroupFetchMethodVersions { + fetch_action_id_signers_power: 0, + fetch_active_action_info: 0, + fetch_action_id_info_keep_serialized: 0, + fetch_action_id_has_signer: 0, + fetch_group_info: 0, + fetch_group_infos: 0, + fetch_action_infos: 0, + fetch_action_signers: 0, + fetch_action_is_closed: 0, + }, + prove: DriveGroupProveMethodVersions { + prove_group_info: 0, + prove_group_infos: 0, + prove_action_infos: 0, + prove_action_signers: 0, + }, + insert: DriveGroupInsertMethodVersions { + add_new_groups: 0, + add_group_action: 1, // changed in v15: the fee-returning wrapper prices before it commits its owned transaction + }, + cost_estimation: DriveGroupCostEstimationMethodVersions { + for_add_group_action: 0, + for_add_group: 0, + }, +}; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index e7713a18891..a98a54b8262 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -1,9 +1,9 @@ use crate::version::drive_versions::drive_address_funds_method_versions::v2::DRIVE_ADDRESS_FUNDS_METHOD_VERSIONS_V2; use crate::version::drive_versions::drive_contract_group_method_versions::v1::DRIVE_CONTRACT_GROUP_METHOD_VERSIONS_V1; -use crate::version::drive_versions::drive_contract_method_versions::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_contract_method_versions::v5::DRIVE_CONTRACT_METHOD_VERSIONS_V5; use crate::version::drive_versions::drive_credit_pool_method_versions::v1::CREDIT_POOL_METHOD_VERSIONS_V1; use crate::version::drive_versions::drive_document_method_versions::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4; -use crate::version::drive_versions::drive_group_method_versions::v1::DRIVE_GROUP_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_group_method_versions::v2::DRIVE_GROUP_METHOD_VERSIONS_V2; use crate::version::drive_versions::drive_group_method_versions::DriveShieldedMethodVersions; use crate::version::drive_versions::drive_grove_method_versions::v1::DRIVE_GROVE_METHOD_VERSIONS_V1; use crate::version::drive_versions::drive_identity_method_versions::v3::DRIVE_IDENTITY_METHOD_VERSIONS_V3; @@ -39,6 +39,14 @@ use grovedb_version::version::v4::GROVE_V4; /// turns on `update.credit_storage_refunds_to_owners`, the primitive /// block lifecycle paths use to credit each recorded owner of a refund /// and report the amount whose owner has no balance element. +/// * **Price before commit** — `batch_operations.apply_drive_operations` +/// 1 -> 2, `DRIVE_GROUP_METHOD_VERSIONS_V2` (`insert.add_group_action` +/// 0 -> 1) and `DRIVE_CONTRACT_METHOD_VERSIONS_V5` (the four moderation +/// writers that can free flagged bytes, 0 -> 1). Every fee-returning +/// entry point that owns its transaction when the caller passes none +/// now prices the batch before committing, so the missing-history error +/// above never leaves a write persisted without its fee result. With a +/// caller transaction nothing changes. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -71,7 +79,7 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { }, document: DRIVE_DOCUMENT_METHOD_VERSIONS_V4, // changed in v9: v2 index walkers + v1 update walker (shared-prefix aggregate indexes become insertable) and the detect_ranked_mode slot vote: DRIVE_VOTE_METHOD_VERSIONS_V2, - contract: DRIVE_CONTRACT_METHOD_VERSIONS_V4, // changed in v9: add_contract_to_storage v1 writes the contract version item beside the contract; update_contract v2 creates the distribution storage and mints the base supply of tokens added by an update + contract: DRIVE_CONTRACT_METHOD_VERSIONS_V5, // changed in v10: the moderation removal wrappers price before committing an owned transaction fees: DriveFeesMethodVersions { calculate_fee: 1 }, // changed in v10: fee history required and consulted for every storage refund estimated_costs: DriveEstimatedCostsMethodVersions { add_estimation_costs_for_levels_up_to_contract: 0, @@ -104,7 +112,7 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4, // changed: document_from_action generation 1 stamps built documents with the contract version (create assigns, replace re-assigns; paired with document serialization format 3) batch_operations: DriveBatchOperationsMethodVersion { convert_drive_operations_to_grove_operations: 0, - apply_drive_operations: 1, // changed: a batch carrying a storage refund forfeiture (a moderator's document deletion) refunds nobody + apply_drive_operations: 2, // changed in v10: the batch is priced before an owned transaction commits }, platform_state: DrivePlatformStateMethodVersions { fetch_platform_state_bytes: 0, @@ -126,7 +134,7 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { estimated_cost_for_prefunded_specialized_balance_update: 1, // changed: the prefunded balances layer holds three trees, the voting balances and the two contract fee pot trees empty_prefunded_specialized_balance: 0, }, - group: DRIVE_GROUP_METHOD_VERSIONS_V1, + group: DRIVE_GROUP_METHOD_VERSIONS_V2, // changed in v10: add_group_action prices before committing an owned transaction contract_group: DRIVE_CONTRACT_GROUP_METHOD_VERSIONS_V1, address_funds: DRIVE_ADDRESS_FUNDS_METHOD_VERSIONS_V2, shielded: DriveShieldedMethodVersions { diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 095de503c4e..78d5c84d378 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -47,6 +47,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// permission and reports the amount whose owner has no balance element, /// so block lifecycle paths can settle it into the current epoch's /// processing pool. +/// 3. **Pricing before commit**: the Drive entry points that own their +/// transaction when a caller passes none (`apply_drive_operations` v2, +/// `add_group_action` v1, the moderation removal wrappers v1) price the +/// batch before committing it, so the error in item 1 never leaves a +/// write persisted without its fee result. /// /// Everything else matches v14. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { From 0d95ce381d6a4370825ea4c62d0dfa6e5597b9c5 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Wed, 23 Sep 2026 01:12:13 -0500 Subject: [PATCH 18/20] test(drive): assert a rejected refund pricing leaves state untouched on both sides of the gate Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../moderation/document_removal_tests.rs | 5 +- .../src/drive/contract/moderation/tests.rs | 201 +++++++++++++++++- packages/rs-drive/src/drive/group/mod.rs | 69 ++++++ 3 files changed, 272 insertions(+), 3 deletions(-) diff --git a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs index 127c5ac2fba..9b03de2e9c0 100644 --- a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs @@ -1041,8 +1041,11 @@ fn should_keep_refunding_a_batch_without_the_forfeiture_before_protocol_version_ .apply_drive_operations, 0 ); + // Protocol version 14 introduced generation 1 and is frozen at it; later versions may + // select later generations, which carry the forfeiture forward. assert_eq!( - PlatformVersion::latest() + PlatformVersion::get(14) + .expect("expected protocol version 14") .drive .methods .batch_operations diff --git a/packages/rs-drive/src/drive/contract/moderation/tests.rs b/packages/rs-drive/src/drive/contract/moderation/tests.rs index 326bdee33cc..8bd91a95946 100644 --- a/packages/rs-drive/src/drive/contract/moderation/tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/tests.rs @@ -6,6 +6,7 @@ use crate::drive::contract::paths::{ CONTRACT_VERSION_KEY, CONTRACT_WARNINGS_KEY, }; use crate::drive::{Drive, RootTree}; +use crate::error::drive::DriveError; use crate::error::Error; use crate::util::batch::drive_op_batch::ContractModerationOperationType; use crate::util::batch::DriveOperation; @@ -163,6 +164,26 @@ fn unsuspend( ) } +fn unwarn( + drive: &Drive, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + apply: bool, + platform_version: &PlatformVersion, +) -> Result { + apply_moderation( + drive, + ContractModerationOperationType::RemoveWarnings { + contract_id, + identity_id, + }, + block_info, + apply, + platform_version, + ) +} + /// Replaces the identity's existing suspension entry. #[allow(clippy::too_many_arguments)] fn resuspend( @@ -1235,8 +1256,7 @@ fn should_warn_accumulate_clear_and_prove_the_status_and_the_entries() { }], ); - let fee = drive - .remove_contract_warnings(contract_id, target, &later, true, None, platform_version) + let fee = unwarn(&drive, contract_id, target, &later, true, platform_version) .expect("expected to clear the warnings"); assert!( fee.fee_refunds @@ -1412,3 +1432,180 @@ fn should_keep_the_documents_on_top_of_the_contract_subtree_and_the_banlist_on_t ); } } + +#[test] +fn should_leave_a_ban_in_place_when_the_bare_wrapper_cannot_price_its_removal() { + // The bare wrapper passes no fee history. Removing a moderator-flagged entry + // needs it from protocol version 15, and the wrapper now prices before it commits + // its owned transaction, so the rejected removal leaves the entry exactly as it + // was. The frozen generation at protocol version 14 still prices and removes it. + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = moderated_contract_keeping(true, true, true); + insert(&drive, &contract, platform_version); + let contract_id = contract.id(); + let moderator = contract.owner_id(); + let target = identity(0x61); + + drive + .add_contract_ban( + contract_id, + target, + &reason("spam"), + moderator, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to ban"); + drive + .add_contract_suspension( + contract_id, + target, + 10, + &reason("flooding"), + false, + moderator, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to suspend"); + drive + .add_contract_warning( + contract_id, + target, + &[warning(1, "spam")], + false, + moderator, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to warn"); + let before = root_hash(&drive, platform_version); + + for result in [ + drive.remove_contract_warnings( + contract_id, + target, + &BlockInfo::default(), + true, + None, + platform_version, + ), + drive.remove_contract_ban( + contract_id, + target, + &BlockInfo::default(), + true, + None, + platform_version, + ), + drive.remove_contract_suspension( + contract_id, + target, + &BlockInfo::default(), + true, + None, + platform_version, + ), + drive.add_contract_suspension( + contract_id, + target, + 20, + &reason("f"), + true, + moderator, + &BlockInfo::default(), + true, + None, + platform_version, + ), + ] { + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "freeing flagged bytes without a fee history must be rejected, got {:?}", + result + ); + } + assert_eq!( + root_hash(&drive, platform_version), + before, + "a rejected removal must not persist" + ); + assert_status( + &drive, + contract_id, + target, + &[ + ContractModerationList::Banlist, + ContractModerationList::Suspensions, + ContractModerationList::Warnings, + ], + ContractModerationStatus { + ban: Some(ContractBan { + reason: reason("spam"), + }), + suspension: Some(ContractSuspension { + until: 10, + reason: reason("flooding"), + }), + warnings: vec![warning(1, "spam")], + }, + ); + + // Estimation writes nothing and needs no history at any version. + let estimated = drive + .remove_contract_ban( + contract_id, + target, + &BlockInfo::default(), + false, + None, + platform_version, + ) + .expect("expected to estimate an unban"); + assert!(estimated.processing_fee > 0); + assert_eq!(root_hash(&drive, platform_version), before); + + let frozen_platform_version = PlatformVersion::get(14).expect("protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(frozen_platform_version)); + let contract = moderated_contract(true, false); + insert(&drive, &contract, frozen_platform_version); + drive + .add_contract_ban( + contract.id(), + target, + &reason("spam"), + contract.owner_id(), + &BlockInfo::default(), + true, + None, + frozen_platform_version, + ) + .expect("expected to ban"); + drive + .remove_contract_ban( + contract.id(), + target, + &BlockInfo::default(), + true, + None, + frozen_platform_version, + ) + .expect("protocol version 14 prices the shipped shortcut without a history"); + assert_status( + &drive, + contract.id(), + target, + &[ContractModerationList::Banlist], + ContractModerationStatus::default(), + ); +} diff --git a/packages/rs-drive/src/drive/group/mod.rs b/packages/rs-drive/src/drive/group/mod.rs index c7973afdbd3..5ab181fb192 100644 --- a/packages/rs-drive/src/drive/group/mod.rs +++ b/packages/rs-drive/src/drive/group/mod.rs @@ -801,6 +801,37 @@ mod tests { "closing without fee history must be rejected at the latest version, got {:?}", result ); + // The wrapper owned the transaction, so the rejected close left no trace: + // the action is still active and nothing was moved to the closed tree. + let is_closed = drive + .fetch_action_is_closed( + contract_id, + 0, + action_id, + true, + None, + &mut vec![], + platform_version, + ) + .expect("expected to check if action is closed"); + assert!( + !is_closed, + "a rejected close must not persist: the action is still active" + ); + let closed_signers = drive + .fetch_action_signers( + contract_id, + 0, + GroupActionStatus::ActionClosed, + action_id, + None, + platform_version, + ) + .expect("expected to fetch closed signers"); + assert!( + closed_signers.is_empty(), + "nothing moved to the closed tree" + ); let (drive, contract_id, _identity_1_id, identity_2_id, action_id) = setup_drive_with_contract_and_action(); @@ -823,6 +854,44 @@ mod tests { .expect("protocol version 14 prices the shipped shortcut without a history"); } + #[test] + fn should_commit_the_owned_transaction_when_pricing_succeeds_at_the_latest_version() { + // An opening call frees no flagged bytes, so the wrapper prices it without a + // history at every version; generation 1 must still commit what it wrote. + let (drive, contract_id, _identity_1_id, identity_2_id, action_id) = + setup_drive_with_contract_and_action(); + let platform_version = PlatformVersion::latest(); + + let fee_result = drive + .add_group_action( + contract_id, + 0, + None, + false, + action_id, + identity_2_id, + 2, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the second signer"); + assert!(fee_result.processing_fee > 0); + + let signers = drive + .fetch_action_signers( + contract_id, + 0, + GroupActionStatus::ActionActive, + action_id, + None, + platform_version, + ) + .expect("expected to fetch signers"); + assert_eq!(signers.len(), 2, "the write was committed"); + } + #[test] fn should_close_group_action_with_new_action_info() { let drive = setup_drive_with_initial_state_structure(None); From aaeb6cbecb78810198851c6991f4508b6779a73b Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Thu, 24 Sep 2026 11:55:24 -0500 Subject: [PATCH 19/20] fix(drive)!: price document and contract writes before committing the transaction drive owns The six fee-returning document wrappers (add, delete, delete by contract id, update, update by contract id, update with serialization), update_contract and apply_contract_with_serialization committed the transaction they own before pricing, so at protocol version 15 the missing-fee-history error could return with the write persisted. New generations (document wrappers v1, update_contract v3, apply_contract_with_serialization v1) hold the owned transaction until Drive::calculate_fee succeeded; the operation builders, the element writer and the _apply_and_add_to_operations methods production uses are untouched and their dispatchers accept the new numbers. The contract cache keeps its committed path under an owned transaction: contracts are looked up through the caller's transaction and a rewritten copy replaces the global entry only after the commit. DRIVE_DOCUMENT_METHOD_VERSIONS_V5 and DRIVE_CONTRACT_METHOD_VERSIONS_V5 select them in drive table v10. Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../apply_contract_with_serialization/mod.rs | 18 ++- .../v1/mod.rs | 80 +++++++++++ .../contract/update/update_contract/mod.rs | 23 ++- .../contract/update/update_contract/v3/mod.rs | 133 ++++++++++++++++++ .../delete_document_for_contract/mod.rs | 13 +- .../delete_document_for_contract/v1/mod.rs | 72 ++++++++++ .../delete_document_for_contract_id/mod.rs | 13 +- .../delete_document_for_contract_id/v1/mod.rs | 95 +++++++++++++ .../insert/add_document_for_contract/mod.rs | 12 +- .../add_document_for_contract/v1/mod.rs | 64 +++++++++ .../update_document_for_contract/mod.rs | 15 +- .../update_document_for_contract/v1/mod.rs | 86 +++++++++++ .../update_document_for_contract_id/mod.rs | 15 +- .../update_document_for_contract_id/v1/mod.rs | 111 +++++++++++++++ .../mod.rs | 16 ++- .../v1/mod.rs | 91 ++++++++++++ .../drive_contract_method_versions/v5.rs | 29 ++-- .../drive_document_method_versions/mod.rs | 1 + .../drive_document_method_versions/v5.rs | 36 +++++ .../src/version/drive_versions/v10.rs | 21 +-- .../rs-platform-version/src/version/v15.rs | 8 +- 21 files changed, 917 insertions(+), 35 deletions(-) create mode 100644 packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/contract/update/update_contract/v3/mod.rs create mode 100644 packages/rs-drive/src/drive/document/delete/delete_document_for_contract/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/document/insert/add_document_for_contract/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/document/update/update_document_for_contract/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/document/update/update_document_for_contract_id/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/document/update/update_document_with_serialization_for_contract/v1/mod.rs create mode 100644 packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs diff --git a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/mod.rs b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/mod.rs index 44ae172aafe..a2da0562888 100644 --- a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/mod.rs +++ b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/mod.rs @@ -14,6 +14,7 @@ use std::borrow::Cow; use std::collections::HashMap; mod v0; +mod v1; /// Drive contract application methods. impl Drive { @@ -71,9 +72,18 @@ impl Drive { transaction, platform_version, ), + 1 => self.apply_contract_with_serialization_v1( + contract, + contract_serialization, + block_info, + apply, + storage_flags, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "apply_contract_with_serialization".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -127,7 +137,9 @@ impl Drive { .apply .apply_contract_with_serialization { - 0 => self.apply_contract_with_serialization_operations_v0( + // Generation 1 of the wrapper changes only when its owned transaction commits; + // the operation builder is generation 0's. + 0 | 1 => self.apply_contract_with_serialization_operations_v0( contract, contract_serialization, block_info, @@ -138,7 +150,7 @@ impl Drive { ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "apply_contract_with_serialization_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v1/mod.rs b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v1/mod.rs new file mode 100644 index 00000000000..649197b988d --- /dev/null +++ b/packages/rs-drive/src/drive/contract/apply/apply_contract_with_serialization/v1/mod.rs @@ -0,0 +1,80 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::CalculatedCostOperation; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::DataContract; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::borrow::Cow; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from the previous one in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Earlier generations applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, a call passing + /// no history persisted the write and then failed. It now fails before anything is + /// written. With a caller transaction nothing is committed by Drive in either + /// generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn apply_contract_with_serialization_v1( + &self, + contract: &DataContract, + contract_serialization: Vec, + block_info: BlockInfo, + apply: bool, + storage_flags: Option>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + let mut cost_operations = vec![]; + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + let batch_operations = self.apply_contract_with_serialization_operations( + contract, + contract_serialization, + &block_info, + &mut estimated_costs_only_with_layer_info, + storage_flags, + transaction, + platform_version, + )?; + let fetch_cost = LowLevelDriveOperation::combine_cost_operations(&batch_operations); + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut cost_operations, + &platform_version.drive, + )?; + cost_operations.push(CalculatedCostOperation(fetch_cost)); + + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(cost_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + Ok(fees) + } +} diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs index 5b838566042..b9c9167e200 100644 --- a/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs @@ -1,6 +1,7 @@ mod v0; mod v1; mod v2; +mod v3; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -82,9 +83,17 @@ impl Drive { platform_version, previous_fee_versions, ), + 3 => self.update_contract_v3( + contract, + block_info, + apply, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_contract".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, })), } @@ -153,7 +162,9 @@ impl Drive { drive_operations, platform_version, ), - 2 => self.update_contract_element_v2( + // Generation 3 of the wrapper changes only when its owned transaction commits; + // the element writer is generation 2's. + 2 | 3 => self.update_contract_element_v2( contract_element, contract, original_contract, @@ -164,7 +175,7 @@ impl Drive { ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_contract_element".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, })), } @@ -240,7 +251,9 @@ impl Drive { drive_operations, platform_version, ), - 2 => self.update_contract_add_operations_v2( + // Generation 3 of the wrapper changes only when its owned transaction commits; + // the batch builder is generation 2's. + 2 | 3 => self.update_contract_add_operations_v2( contract_element, contract, original_contract, @@ -252,7 +265,7 @@ impl Drive { ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_contract_add_operations".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, })), } diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/v3/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/v3/mod.rs new file mode 100644 index 00000000000..bfa170ec8d7 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/update/update_contract/v3/mod.rs @@ -0,0 +1,133 @@ +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v0::DataContractConfigGettersV0; +use dpp::data_contract::DataContract; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::fee_result::FeeResult; +use dpp::serialization::PlatformSerializableWithPlatformVersion; +use dpp::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; + +impl Drive { + /// Generation 3 differs from generation 2 in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Earlier generations applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, a call passing + /// no history persisted the write and then failed. It now fails before anything is + /// written, and the contract cache stays on its committed path: the rewritten copy + /// replaces the global entry only after the commit. With a caller transaction nothing is + /// committed by Drive in either generation and the block cache is used as before. + #[inline(always)] + pub(super) fn update_contract_v3( + &self, + contract: &DataContract, + block_info: BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + if !apply { + return self.insert_contract( + contract, + block_info, + false, + transaction, + platform_version, + ); + } + // The contract cache keys its block-versus-committed behaviour on whether a + // transaction is present. An owned transaction is not block execution, so cache + // reads and writes below stay on the committed path (`caller_transaction`), and + // the rewritten copy is seeded only once the owned transaction has committed. + let caller_transaction = transaction; + let owned_transaction = transaction + .is_none() + .then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(caller_transaction); + + let mut drive_operations: Vec = vec![]; + + let contract_bytes = contract.serialize_to_bytes_with_platform_version(platform_version)?; + + // Since we can update the contract by definition it already has storage flags + let storage_flags = Some(StorageFlags::new_single_epoch( + block_info.epoch.index, + Some(contract.owner_id().to_buffer()), + )); + + let contract_element = Element::Item( + contract_bytes, + StorageFlags::map_to_some_element_flags(storage_flags.as_ref()), + ); + + let original_contract_fetch_info = self + .get_contract_with_fetch_info_and_add_to_operations( + contract.id().to_buffer(), + Some(&block_info.epoch), + true, + caller_transaction, + &mut drive_operations, + platform_version, + )? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "contract should exist", + )))?; + + if original_contract_fetch_info.contract.config().readonly() { + return Err(Error::Drive(DriveError::UpdatingReadOnlyImmutableContract( + "original contract is readonly", + ))); + } + + self.update_contract_element( + contract_element, + contract, + &original_contract_fetch_info.contract, + &block_info, + transaction, + &mut drive_operations, + platform_version, + )?; + + // Update DataContracts cache with the new contract + let updated_contract_fetch_info = self + .fetch_contract_and_add_operations( + contract.id().to_buffer(), + Some(&block_info.epoch), + transaction, + &mut drive_operations, + platform_version, + )? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "contract should exist", + )))?; + + // A pricing error drops the owned transaction with everything it wrote, and the + // cache never learns of the rewrite. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + // Update DataContracts cache with the new contract + self.cache + .data_contracts + .insert_rewritten(updated_contract_fetch_info, caller_transaction.is_some()); + Ok(fees) + } +} diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/mod.rs index 616d27de6c6..f28fd284ba7 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -59,9 +60,19 @@ impl Drive { platform_version, previous_fee_versions, ), + 1 => self.delete_document_for_contract_v1( + document_id, + contract, + document_type_name, + block_info, + apply, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "delete_document_for_contract".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/v1/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/v1/mod.rs new file mode 100644 index 00000000000..6a2dabad812 --- /dev/null +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract/v1/mod.rs @@ -0,0 +1,72 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::DataContract; +use dpp::fee::fee_result::FeeResult; + +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from the previous one in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Earlier generations applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, a call passing + /// no history persisted the write and then failed. It now fails before anything is + /// written. With a caller transaction nothing is committed by Drive in either + /// generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn delete_document_for_contract_v1( + &self, + document_id: Identifier, + contract: &DataContract, + document_type_name: &str, + block_info: BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + let mut drive_operations: Vec = vec![]; + let estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + self.delete_document_for_contract_apply_and_add_to_operations( + document_id, + contract, + document_type_name, + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + &mut drive_operations, + platform_version, + )?; + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + Ok(fees) + } +} diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/mod.rs index 3737d85c356..1a70a431e92 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use grovedb::TransactionArg; @@ -60,9 +61,19 @@ impl Drive { platform_version, previous_fee_versions, ), + 1 => self.delete_document_for_contract_id_v1( + document_id, + contract_id, + document_type_name, + block_info, + apply, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "delete_document_for_contract_id".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/v1/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/v1/mod.rs new file mode 100644 index 00000000000..6e61ca3cb63 --- /dev/null +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_id/v1/mod.rs @@ -0,0 +1,95 @@ +use grovedb::batch::KeyInfoPath; + +use grovedb::{EstimatedLayerInformation, TransactionArg}; + +use std::collections::HashMap; + +use crate::drive::Drive; +use crate::error::document::DocumentError; +use dpp::block::block_info::BlockInfo; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; + +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; + +impl Drive { + /// Generation 1 differs from the previous one in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Earlier generations applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, a call passing + /// no history persisted the write and then failed. It now fails before anything is + /// written. With a caller transaction nothing is committed by Drive in either + /// generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn delete_document_for_contract_id_v1( + &self, + document_id: Identifier, + contract_id: Identifier, + document_type_name: &str, + block_info: BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + // The contract cache keys its block-versus-committed behaviour on whether a + // transaction is present. An owned transaction is not block execution, so the + // contract is looked up through the caller's (`caller_transaction`) and only the + // document write goes through the owned one. + let caller_transaction = transaction; + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(caller_transaction); + let mut drive_operations: Vec = vec![]; + let estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let contract_fetch_info = self + .get_contract_with_fetch_info_and_add_to_operations( + contract_id.to_buffer(), + Some(&block_info.epoch), + true, + caller_transaction, + &mut drive_operations, + platform_version, + )? + .ok_or(Error::Document(DocumentError::DataContractNotFound))?; + + let contract = &contract_fetch_info.contract; + + self.delete_document_for_contract_apply_and_add_to_operations( + document_id, + contract, + document_type_name, + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + &mut drive_operations, + platform_version, + )?; + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + Ok(fees) + } +} diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs index 28310f15ada..5637a36715d 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::util::object_size_info::DocumentAndContractInfo; @@ -54,9 +55,18 @@ impl Drive { platform_version, previous_fee_versions, ), + 1 => self.add_document_for_contract_v1( + document_and_contract_info, + override_document, + block_info, + apply, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_document_for_contract".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/v1/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/v1/mod.rs new file mode 100644 index 00000000000..1a2ded38cc6 --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/v1/mod.rs @@ -0,0 +1,64 @@ +use crate::drive::Drive; +use crate::util::object_size_info::DocumentAndContractInfo; + +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; + +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Generation 1 differs from the previous one in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Earlier generations applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, a call passing + /// no history persisted the write and then failed. It now fails before anything is + /// written. With a caller transaction nothing is committed by Drive in either + /// generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_document_for_contract_v1( + &self, + document_and_contract_info: DocumentAndContractInfo, + override_document: bool, + block_info: BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + let mut drive_operations: Vec = vec![]; + self.add_document_for_contract_apply_and_add_to_operations( + document_and_contract_info, + override_document, + &block_info, + true, + apply, + transaction, + &mut drive_operations, + platform_version, + )?; + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + Ok(fees) + } +} diff --git a/packages/rs-drive/src/drive/document/update/update_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/update/update_document_for_contract/mod.rs index 5c3658a7b2f..a549b6dfebe 100644 --- a/packages/rs-drive/src/drive/document/update/update_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/update/update_document_for_contract/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::util::storage_flags::StorageFlags; @@ -68,9 +69,21 @@ impl Drive { platform_version, previous_fee_versions, ), + 1 => self.update_document_for_contract_v1( + document, + contract, + document_type, + owner_id, + block_info, + apply, + storage_flags, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_document_for_contract".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/update/update_document_for_contract/v1/mod.rs b/packages/rs-drive/src/drive/document/update/update_document_for_contract/v1/mod.rs new file mode 100644 index 00000000000..a6feb33ef3d --- /dev/null +++ b/packages/rs-drive/src/drive/document/update/update_document_for_contract/v1/mod.rs @@ -0,0 +1,86 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::fee_result::FeeResult; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::borrow::Cow; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from the previous one in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Earlier generations applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, a call passing + /// no history persisted the write and then failed. It now fails before anything is + /// written. With a caller transaction nothing is committed by Drive in either + /// generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn update_document_for_contract_v1( + &self, + document: &Document, + contract: &DataContract, + document_type: DocumentTypeRef, + owner_id: Option<[u8; 32]>, + block_info: BlockInfo, + apply: bool, + storage_flags: Option>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + let mut drive_operations: Vec = vec![]; + let estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let document_info = DocumentRefInfo((document, storage_flags)); + + self.update_document_for_contract_apply_and_add_to_operations( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info, + owner_id, + }, + contract, + document_type, + }, + &block_info, + estimated_costs_only_with_layer_info, + transaction, + &mut drive_operations, + platform_version, + )?; + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + Ok(fees) + } +} diff --git a/packages/rs-drive/src/drive/document/update/update_document_for_contract_id/mod.rs b/packages/rs-drive/src/drive/document/update/update_document_for_contract_id/mod.rs index d6563600f27..88814c194c7 100644 --- a/packages/rs-drive/src/drive/document/update/update_document_for_contract_id/mod.rs +++ b/packages/rs-drive/src/drive/document/update/update_document_for_contract_id/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::util::storage_flags::StorageFlags; @@ -66,9 +67,21 @@ impl Drive { platform_version, previous_fee_versions, ), + 1 => self.update_document_for_contract_id_v1( + serialized_document, + contract_id, + document_type, + owner_id, + block_info, + apply, + storage_flags, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_document_for_contract_id".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/update/update_document_for_contract_id/v1/mod.rs b/packages/rs-drive/src/drive/document/update/update_document_for_contract_id/v1/mod.rs new file mode 100644 index 00000000000..c8a5e5b82fc --- /dev/null +++ b/packages/rs-drive/src/drive/document/update/update_document_for_contract_id/v1/mod.rs @@ -0,0 +1,111 @@ +use crate::drive::Drive; +use crate::error::document::DocumentError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::DocumentInfo::DocumentRefAndSerialization; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; + +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::Document; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::fee_result::FeeResult; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::borrow::Cow; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from the previous one in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Earlier generations applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, a call passing + /// no history persisted the write and then failed. It now fails before anything is + /// written. With a caller transaction nothing is committed by Drive in either + /// generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn update_document_for_contract_id_v1( + &self, + serialized_document: &[u8], + contract_id: [u8; 32], + document_type: &str, + owner_id: Option<[u8; 32]>, + block_info: BlockInfo, + apply: bool, + storage_flags: Option>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + // The contract cache keys its block-versus-committed behaviour on whether a + // transaction is present. An owned transaction is not block execution, so the + // contract is looked up through the caller's (`caller_transaction`) and only the + // document write goes through the owned one. + let caller_transaction = transaction; + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(caller_transaction); + let mut drive_operations: Vec = vec![]; + let estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let contract_fetch_info = self + .get_contract_with_fetch_info_and_add_to_operations( + contract_id, + Some(&block_info.epoch), + true, + caller_transaction, + &mut drive_operations, + platform_version, + )? + .ok_or(Error::Document(DocumentError::DataContractNotFound))?; + + let contract = &contract_fetch_info.contract; + + let document_type = contract.document_type_for_name(document_type)?; + + let document = Document::from_bytes(serialized_document, document_type, platform_version)?; + + let document_info = + DocumentRefAndSerialization((&document, serialized_document, storage_flags)); + + self.update_document_for_contract_apply_and_add_to_operations( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info, + owner_id, + }, + contract, + document_type, + }, + &block_info, + estimated_costs_only_with_layer_info, + transaction, + &mut drive_operations, + platform_version, + )?; + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + Ok(fees) + } +} diff --git a/packages/rs-drive/src/drive/document/update/update_document_with_serialization_for_contract/mod.rs b/packages/rs-drive/src/drive/document/update/update_document_with_serialization_for_contract/mod.rs index b6f5740684e..bdbfc6e2e4f 100644 --- a/packages/rs-drive/src/drive/document/update/update_document_with_serialization_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/update/update_document_with_serialization_for_contract/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::util::storage_flags::StorageFlags; @@ -70,9 +71,22 @@ impl Drive { platform_version, previous_fee_versions, ), + 1 => self.update_document_with_serialization_for_contract_v1( + document, + serialized_document, + contract, + document_type_name, + owner_id, + block_info, + apply, + storage_flags, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_document_with_serialization_for_contract".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/update/update_document_with_serialization_for_contract/v1/mod.rs b/packages/rs-drive/src/drive/document/update/update_document_with_serialization_for_contract/v1/mod.rs new file mode 100644 index 00000000000..5e9332a1c55 --- /dev/null +++ b/packages/rs-drive/src/drive/document/update/update_document_with_serialization_for_contract/v1/mod.rs @@ -0,0 +1,91 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::DocumentInfo::DocumentRefAndSerialization; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::fee::fee_result::FeeResult; + +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::borrow::Cow; +use std::collections::HashMap; + +impl Drive { + /// Generation 1 differs from the previous one in one thing: when the caller supplies no + /// transaction and the operations are applied, the write and its pricing share one + /// owned transaction that is committed only after `Drive::calculate_fee` succeeded. + /// Earlier generations applied the batch (committing it on its own without a caller + /// transaction) and priced it afterwards, so from protocol version 15, where pricing an + /// owner-attributed storage removal without the fee history is an error, a call passing + /// no history persisted the write and then failed. It now fails before anything is + /// written. With a caller transaction nothing is committed by Drive in either + /// generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn update_document_with_serialization_for_contract_v1( + &self, + document: &Document, + serialized_document: &[u8], + contract: &DataContract, + document_type_name: &str, + owner_id: Option<[u8; 32]>, + block_info: BlockInfo, + apply: bool, + storage_flags: Option>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + let mut drive_operations: Vec = vec![]; + let estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let document_type = contract.document_type_for_name(document_type_name)?; + + let document_info = + DocumentRefAndSerialization((document, serialized_document, storage_flags)); + + self.update_document_for_contract_apply_and_add_to_operations( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info, + owner_id, + }, + contract, + document_type, + }, + &block_info, + estimated_costs_only_with_layer_info, + transaction, + &mut drive_operations, + platform_version, + )?; + // A pricing error drops the owned transaction with everything it wrote. + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + Ok(fees) + } +} diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs index 7fbbaf09f28..166a85a0793 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs @@ -1,20 +1,31 @@ use crate::version::drive_versions::drive_contract_method_versions::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4; use crate::version::drive_versions::drive_contract_method_versions::{ - DriveContractMethodVersions, DriveContractModerationMethodVersions, + DriveContractApplyMethodVersions, DriveContractMethodVersions, + DriveContractModerationMethodVersions, DriveContractUpdateMethodVersions, }; /// Drive contract methods for protocol version 15. /// -/// Relative to [`super::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4`], the four moderation -/// writers that can free moderator-flagged bytes (`remove_contract_ban`, -/// `remove_contract_suspension`, `remove_contract_warnings`, and `add_contract_suspension`, -/// whose replacement of an existing entry may shrink it) are bumped to `1`: when the caller passes no transaction, -/// the fee-returning wrapper writes and prices inside one owned transaction and commits it -/// only once `Drive::calculate_fee` succeeded. Pricing an owner-attributed storage removal -/// without the fee history is an error from this version, and generation 0 committed the -/// write before that error surfaced. The operation builders are unchanged. +/// Relative to [`super::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4`], every fee-returning +/// contract writer that can free owner- or moderator-flagged bytes gets a generation that, +/// when the caller passes no transaction, writes and prices inside one owned transaction and +/// commits it only once `Drive::calculate_fee` succeeded: `update.update_contract` 2 -> 3 +/// (the element writer stays at generation 2), `apply.apply_contract_with_serialization` +/// 0 -> 1 (its operation builder stays at 0), and the four moderation writers +/// `remove_contract_ban`, `remove_contract_suspension`, `remove_contract_warnings` and +/// `add_contract_suspension` (whose replacement of an existing entry may shrink it) 0 -> 1. +/// Pricing an owner-attributed storage removal without the fee history is an error from this +/// version, and the earlier generations committed the write before that error surfaced. pub const DRIVE_CONTRACT_METHOD_VERSIONS_V5: DriveContractMethodVersions = DriveContractMethodVersions { + apply: DriveContractApplyMethodVersions { + apply_contract_with_serialization: 1, + ..DRIVE_CONTRACT_METHOD_VERSIONS_V4.apply + }, + update: DriveContractUpdateMethodVersions { + update_contract: 3, + ..DRIVE_CONTRACT_METHOD_VERSIONS_V4.update + }, moderation: DriveContractModerationMethodVersions { remove_contract_ban: 1, add_contract_suspension: 1, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index 0465a5a7368..319f2b14a24 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -4,6 +4,7 @@ pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; #[derive(Clone, Debug, Default)] pub struct DriveDocumentMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs new file mode 100644 index 00000000000..549c6a05bb2 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs @@ -0,0 +1,36 @@ +use crate::version::drive_versions::drive_document_method_versions::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_document_method_versions::{ + DriveDocumentDeleteMethodVersions, DriveDocumentInsertMethodVersions, + DriveDocumentMethodVersions, DriveDocumentUpdateMethodVersions, +}; + +/// V5 is protocol version 15's document-method table. Relative to +/// [`super::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4`], the six fee-returning wrappers +/// (`add_document_for_contract`, `delete_document_for_contract`, +/// `delete_document_for_contract_id`, `update_document_for_contract`, +/// `update_document_for_contract_id` and `update_document_with_serialization_for_contract`) +/// are bumped to `1`: when the caller passes no transaction they write and price inside one +/// owned transaction and commit it only once `Drive::calculate_fee` succeeded. Pricing an +/// owner-attributed storage removal without the fee history is an error from this version, +/// and generation 0 committed the write before that error surfaced. The operation builders +/// and the `_apply_and_add_to_operations` methods every production caller uses through +/// `apply_drive_operations` are unchanged. +pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V5: DriveDocumentMethodVersions = + DriveDocumentMethodVersions { + insert: DriveDocumentInsertMethodVersions { + add_document_for_contract: 1, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.insert + }, + update: DriveDocumentUpdateMethodVersions { + update_document_for_contract: 1, + update_document_for_contract_id: 1, + update_document_with_serialization_for_contract: 1, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.update + }, + delete: DriveDocumentDeleteMethodVersions { + delete_document_for_contract: 1, + delete_document_for_contract_id: 1, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.delete + }, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4 + }; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index a98a54b8262..267c08ee1f1 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -2,7 +2,7 @@ use crate::version::drive_versions::drive_address_funds_method_versions::v2::DRI use crate::version::drive_versions::drive_contract_group_method_versions::v1::DRIVE_CONTRACT_GROUP_METHOD_VERSIONS_V1; use crate::version::drive_versions::drive_contract_method_versions::v5::DRIVE_CONTRACT_METHOD_VERSIONS_V5; use crate::version::drive_versions::drive_credit_pool_method_versions::v1::CREDIT_POOL_METHOD_VERSIONS_V1; -use crate::version::drive_versions::drive_document_method_versions::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_document_method_versions::v5::DRIVE_DOCUMENT_METHOD_VERSIONS_V5; use crate::version::drive_versions::drive_group_method_versions::v2::DRIVE_GROUP_METHOD_VERSIONS_V2; use crate::version::drive_versions::drive_group_method_versions::DriveShieldedMethodVersions; use crate::version::drive_versions::drive_grove_method_versions::v1::DRIVE_GROVE_METHOD_VERSIONS_V1; @@ -41,12 +41,15 @@ use grovedb_version::version::v4::GROVE_V4; /// and report the amount whose owner has no balance element. /// * **Price before commit** — `batch_operations.apply_drive_operations` /// 1 -> 2, `DRIVE_GROUP_METHOD_VERSIONS_V2` (`insert.add_group_action` -/// 0 -> 1) and `DRIVE_CONTRACT_METHOD_VERSIONS_V5` (the four moderation -/// writers that can free flagged bytes, 0 -> 1). Every fee-returning -/// entry point that owns its transaction when the caller passes none -/// now prices the batch before committing, so the missing-history error -/// above never leaves a write persisted without its fee result. With a -/// caller transaction nothing changes. +/// 0 -> 1), `DRIVE_CONTRACT_METHOD_VERSIONS_V5` (`update_contract` 2 -> 3, +/// `apply_contract_with_serialization` 0 -> 1, the four moderation +/// writers that can free flagged bytes 0 -> 1) and +/// `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` (the six fee-returning document +/// wrappers 0 -> 1). Every fee-returning entry point that owns its +/// transaction when the caller passes none now prices the batch before +/// committing, so the missing-history error above never leaves a write +/// persisted without its fee result. With a caller transaction nothing +/// changes. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -77,9 +80,9 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { remove_from_system_credits_operations: 0, calculate_total_credits_balance: 2, // ShieldedBalances root tree adds a fifth term to the equation }, - document: DRIVE_DOCUMENT_METHOD_VERSIONS_V4, // changed in v9: v2 index walkers + v1 update walker (shared-prefix aggregate indexes become insertable) and the detect_ranked_mode slot + document: DRIVE_DOCUMENT_METHOD_VERSIONS_V5, // changed in v10: the six fee-returning document wrappers price before committing an owned transaction vote: DRIVE_VOTE_METHOD_VERSIONS_V2, - contract: DRIVE_CONTRACT_METHOD_VERSIONS_V5, // changed in v10: the moderation removal wrappers price before committing an owned transaction + contract: DRIVE_CONTRACT_METHOD_VERSIONS_V5, // changed in v10: update_contract v3, apply_contract_with_serialization v1 and the moderation writers price before committing an owned transaction fees: DriveFeesMethodVersions { calculate_fee: 1 }, // changed in v10: fee history required and consulted for every storage refund estimated_costs: DriveEstimatedCostsMethodVersions { add_estimation_costs_for_levels_up_to_contract: 0, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 78d5c84d378..c37dbaefa3b 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -49,9 +49,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// processing pool. /// 3. **Pricing before commit**: the Drive entry points that own their /// transaction when a caller passes none (`apply_drive_operations` v2, -/// `add_group_action` v1, the moderation removal wrappers v1) price the -/// batch before committing it, so the error in item 1 never leaves a -/// write persisted without its fee result. +/// the six document wrappers v1, `update_contract` v3, +/// `apply_contract_with_serialization` v1, `add_group_action` v1, the +/// moderation writers v1) price the batch before committing it, so the +/// error in item 1 never leaves a write persisted without its fee +/// result. /// /// Everything else matches v14. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { From 1a7bc4b9efd4a1324d4ab92b856bf640c0f38348 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Thu, 24 Sep 2026 11:55:24 -0500 Subject: [PATCH 20/20] test(drive): assert rejected document and contract pricing leaves state and cache untouched Refs #4675, Refs #4689 Co-Authored-By: Claude Fable 5.1 --- .../document_query/v1/dispatch/chained.rs | 8 +- packages/rs-drive/src/drive/contract/mod.rs | 110 ++++++++++++ .../rs-drive/src/drive/document/delete/mod.rs | 168 +++++++++++++++++- 3 files changed, 284 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs index 2f6d53cc0d3..3b3bd873ae3 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs @@ -265,9 +265,12 @@ mod tests { use dpp::dashcore::Network; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::identifier::Identifier; use dpp::platform_value::Value; use dpp::tests::json_document::json_document_to_contract; + use dpp::version::fee::FeeVersion; + use std::collections::BTreeMap; const YAPPR_CONTRACT_PATH: &str = "../rs-drive/tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json"; @@ -480,6 +483,9 @@ mod tests { fn should_report_a_deleted_post_of_a_deletable_document_join() { let (platform, state, version, contract) = setup_yappr_state_at(YAPPR_DELETABLE_POSTS_CONTRACT_PATH); + // The post is owner-flagged, so pricing its removal needs the fee history of the + // removing block, as every production caller passes. + let fee_history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); platform .drive .delete_document_for_contract( @@ -490,7 +496,7 @@ mod tests { true, None, version, - None, + Some(&fee_history), ) .expect("expected to delete the post"); diff --git a/packages/rs-drive/src/drive/contract/mod.rs b/packages/rs-drive/src/drive/contract/mod.rs index 0fcf9567739..665311049a5 100644 --- a/packages/rs-drive/src/drive/contract/mod.rs +++ b/packages/rs-drive/src/drive/contract/mod.rs @@ -84,7 +84,10 @@ mod tests { use crate::drive::identity::key::fetch::{IdentityKeysRequest, KeyIDIdentityPublicKeyPairVec}; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::version::fee::FeeVersion; use dpp::version::PlatformVersion; + use std::collections::BTreeMap; #[allow(dead_code)] #[deprecated(note = "This function is marked as unused.")] @@ -950,6 +953,113 @@ mod tests { ); } + /// A contract update rewrites the owner-flagged contract item, so pricing it without the + /// fee history is rejected from protocol version 15. The wrapper owns its transaction when + /// the caller passes none, so the rejected update leaves the stored contract, the cached + /// copy and the root hash as they were; with the history it commits and the cache follows. + #[test] + fn should_leave_a_contract_in_place_when_the_wrapper_cannot_price_its_update() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + // `insert_contract` flags a mutable contract's item with its owner, so an update + // rewrites owner-attributed bytes. + let contract = json_document_to_contract( + "tests/supporting_files/contract/references/references.json", + false, + platform_version, + ) + .expect("expected to get a contract"); + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the contract"); + let contract_id = contract.id().to_buffer(); + let root_hash = |drive: &Drive| { + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected a root hash") + }; + let cached_version = |drive: &Drive| { + drive + .get_cached_contract_with_fetch_info(contract_id, None, &platform_version.drive) + .expect("expected the cache lookup") + .map(|fetch_info| fetch_info.contract.version()) + }; + // Warm the committed cache so a rejected update has a stale copy to leave alone. + drive + .get_contract_with_fetch_info_and_fee(contract_id, None, true, None, platform_version) + .expect("expected to fetch the contract"); + assert_eq!(cached_version(&drive), Some(contract.version())); + let before = root_hash(&drive); + + // The rewrite shrinks the contract item: a smaller `note` schema replaces the + // large one, so owner-flagged bytes are freed and must be priced with the history. + let mut updated = contract.clone(); + updated.set_version(contract.version() + 1); + let note_schema = platform_value!({ + "type": "object", + "properties": { + "abc0": {"type": "string", "maxLength": 63, "position": 0} + }, + "additionalProperties": false, + }); + updated + .set_document_schema("note", note_schema, true, &mut vec![], platform_version) + .expect("should set a document schema"); + + let result = drive.update_contract( + &updated, + BlockInfo::default(), + true, + None, + platform_version, + None, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "rewriting owner-flagged bytes without a fee history must be rejected, got {:?}", + result + ); + assert_eq!( + root_hash(&drive), + before, + "a rejected update must not persist" + ); + assert_eq!( + cached_version(&drive), + Some(contract.version()), + "the cache must not learn of a rewrite that was not committed" + ); + + let history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); + drive + .update_contract( + &updated, + BlockInfo::default(), + true, + None, + platform_version, + Some(&history), + ) + .expect("expected to update the contract with the fee history"); + assert_ne!(root_hash(&drive), before, "the update was committed"); + assert_eq!( + cached_version(&drive), + Some(updated.version()), + "the committed rewrite replaced the cached copy" + ); + } + #[test] fn test_update_contract_errors_on_readonly() { let drive = setup_drive_with_initial_state_structure(None); diff --git a/packages/rs-drive/src/drive/document/delete/mod.rs b/packages/rs-drive/src/drive/document/delete/mod.rs index e04dc6eb7f7..c7629bfa5af 100644 --- a/packages/rs-drive/src/drive/document/delete/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/mod.rs @@ -74,7 +74,7 @@ mod tests { use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; - use dpp::document::Document; + use dpp::document::{Document, DocumentV0Getters}; use dpp::fee::default_costs::KnownCostItem::StorageDiskUsageCreditPerByte; use dpp::fee::default_costs::{CachedEpochIndexFeeVersions, EpochCosts}; use dpp::identifier::Identifier; @@ -858,6 +858,172 @@ mod tests { .expect("expected to be able to delete the document"); } + /// Deleting an owner-flagged document without the fee history is rejected from + /// protocol version 15, and the public wrapper, owning its transaction when the caller + /// passes none, rejects it before anything is written: the document is still there and + /// the root hash is unchanged. Protocol version 14 still deletes it. + #[test] + fn should_leave_a_document_in_place_when_the_wrapper_cannot_price_its_removal() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + + let contract = setup_contract( + &drive, + "tests/supporting_files/contract/family/family-contract-reduced.json", + None, + None, + None::, + None, + Some(platform_version), + ); + let document_type = contract + .document_type_for_name("person") + .expect("expected to get document type"); + let owner_id = rand::thread_rng().gen::<[u8; 32]>(); + let person_document = json_document_to_document( + "tests/supporting_files/contract/family/person0.json", + Some(owner_id.into()), + document_type, + platform_version, + ) + .expect("expected to get document"); + let storage_flags = Some(Cow::Owned(StorageFlags::SingleEpochOwned(0, owner_id))); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&person_document, storage_flags)), + owner_id: Some(owner_id), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("expected to insert a document successfully"); + let root_hash = |drive: &Drive| { + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected a root hash") + }; + let before = root_hash(&drive); + + let result = drive.delete_document_for_contract( + person_document.id(), + &contract, + "person", + BlockInfo::default(), + true, + None, + platform_version, + None, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "deleting owner-flagged bytes without a fee history must be rejected, got {:?}", + result + ); + assert_eq!( + root_hash(&drive), + before, + "a rejected delete must not persist" + ); + let query = DriveDocumentQuery::from_sql_expr( + "select * from person where firstName = 'Samuel' order by firstName asc limit 100", + &contract, + Some(&DriveConfig::default()), + platform_version, + ) + .expect("should build query"); + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("expected to execute query"); + assert_eq!(results.len(), 1, "the document is still there"); + + // The same delete with the block's history is priced, refunds the owner and commits. + let fee_result = drive + .delete_document_for_contract( + person_document.id(), + &contract, + "person", + BlockInfo::default(), + true, + None, + platform_version, + Some(&EPOCH_CHANGE_FEE_VERSION_TEST), + ) + .expect("expected to delete the document with the fee history"); + assert!(fee_result.fee_refunds.get(&owner_id).is_some()); + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("expected to execute query"); + assert!(results.is_empty(), "the delete was committed"); + + // Protocol version 14 prices the shipped shortcut without a history and commits. + let frozen_platform_version = PlatformVersion::get(14).expect("protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(frozen_platform_version)); + let contract = setup_contract( + &drive, + "tests/supporting_files/contract/family/family-contract-reduced.json", + None, + None, + None::, + None, + Some(frozen_platform_version), + ); + let document_type = contract + .document_type_for_name("person") + .expect("expected to get document type"); + let person_document = json_document_to_document( + "tests/supporting_files/contract/family/person0.json", + Some(owner_id.into()), + document_type, + frozen_platform_version, + ) + .expect("expected to get document"); + let storage_flags = Some(Cow::Owned(StorageFlags::SingleEpochOwned(0, owner_id))); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&person_document, storage_flags)), + owner_id: Some(owner_id), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + frozen_platform_version, + None, + ) + .expect("expected to insert a document successfully"); + drive + .delete_document_for_contract( + person_document.id(), + &contract, + "person", + BlockInfo::default(), + true, + None, + frozen_platform_version, + None, + ) + .expect("protocol version 14 prices the shipped shortcut without a history"); + } + #[test] fn test_delete_dashpay_documents() { let drive = setup_drive_with_initial_state_structure(None);