From 6d8ca6d93adc10da8d230fe7ea54ab0a59de5931 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:31:01 -0500 Subject: [PATCH 01/27] 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/v15.rs | 28 ++- 7 files changed, 379 insertions(+), 7 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 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 6b2cde9a209..ed12ca1def8 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 @@ -170,6 +170,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..f1842e13b2d --- /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: 1, // changed in v14: credits that repay an identity's debt are owed to the processing fee pool + add_to_previous_balance: 0, + apply_balance_change_from_fee_to_identity: 1, // changed in v14: routes the debt a balance change repaid to the processing fee pool + 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..e0182b59f2c --- /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::v3::DRIVE_VOTE_METHOD_VERSIONS_V3; +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_V3, // changed in v9: the end-date cleanup of ended contested vote polls removes an end date only once none of its polls remain + 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; evonode_participation_rewards v1 pays an evonode's claim only through the epochs it read + 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/v15.rs b/packages/rs-platform-version/src/version/v15.rs index e49b5133ace..095de503c4e 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -21,7 +21,7 @@ use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIV 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::v9::DRIVE_VERSION_V9; +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; @@ -30,14 +30,28 @@ use crate::version::ProtocolVersion; pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; -/// Introduced as the activation gate for the consensus changes of the 4.3 -/// line. Functionally identical to v14 at introduction: the same component -/// version structs, no behavior change. Each change that needs this gate -/// lands in its own follow-up and bumps the component table it consumes here; -/// keeping v15 == v14 until then lets mixed-version validators agree. +/// 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_V9, + 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, From 236d9aec735ae9d9aaa2453c344bed67269f7662 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:38:19 -0500 Subject: [PATCH 02/27] 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 | 73 +++++++++++++++++ 8 files changed, 332 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 c4bccd40633..7761f67037f 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; #[cfg(test)] mod debt_test_helpers; mod disable_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 5550da343be..ca16722ce44 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -461,6 +461,79 @@ 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, + lifetime_storage_fees: Default::default(), + }) + } + }) + .collect() + } + /// Returns the cost of this operation pub fn operation_cost(self) -> Result { match self { From 64afeee1bd12715763cee9a5d570d295f399f787 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:49:28 -0500 Subject: [PATCH 03/27] 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 ca16722ce44..5d3ed10a0d7 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -3045,4 +3045,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 34d9470304a273584515e4350bc80bec90c122f4 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:51:14 -0500 Subject: [PATCH 04/27] 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 4dfd271463cb7c34298155ccfbdaa5621e3ade09 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:54:54 -0500 Subject: [PATCH 05/27] 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 e6faacb1c6d965e1a2eb942b3d686461ca0a7676 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:58:45 -0500 Subject: [PATCH 06/27] 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 7318434dd3d58a6fb0e1bd53986ab2663d3ad131 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 01:58:45 -0500 Subject: [PATCH 07/27] 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 94af5b6e964..f418a979a3f 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -468,6 +468,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 @@ -541,6 +569,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 f48f20228b59033afe85865c745054735768992d Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 05:26:09 -0500 Subject: [PATCH 08/27] 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 6b7ef3ae3eb..0951d0cceb2 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"; @@ -1337,6 +1340,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 @@ -1350,7 +1357,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 63cccb00bbc..1769d95ac50 100644 --- a/packages/rs-drive/src/drive/document/update/mod.rs +++ b/packages/rs-drive/src/drive/document/update/mod.rs @@ -3484,7 +3484,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 3fc164ed647a0819cccbe5fb9c0a67ad645bbb8f Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 05:30:05 -0500 Subject: [PATCH 09/27] 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 5d3ed10a0d7..7d8fb3a8c7f 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -3063,6 +3063,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". @@ -3079,9 +3082,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(); @@ -3275,7 +3276,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 2d85fb15ab5063b9b01f43ac3329fbde7eea8be5 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 15:49:49 -0500 Subject: [PATCH 10/27] 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 c42d9c2429747310a850e78028cd70100fba36b0 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 15:49:49 -0500 Subject: [PATCH 11/27] 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 f418a979a3f..f05a992dded 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -485,16 +485,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 afcebcecddc4f79124e10830afeae40b1c3e2714 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 16:52:20 -0500 Subject: [PATCH 12/27] 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 f05a992dded..a937b8c21a5 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). The documents of a type that declares a `ttl` (protocol version 14) are the exception: they carry no storage flags and refund nothing, their bytes are priced for the time they live, and From 9235bac5df7e3c121336a024597d1a8a4e32ddc1 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 18:53:36 -0500 Subject: [PATCH 13/27] 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 f6152d4a9ddd49870c0efa66e9027b2ce7d23f56 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 22 Sep 2026 13:17:47 -0500 Subject: [PATCH 14/27] 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 61bfff0759b7a586146df2988b4c09af4c3ea452 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 22 Sep 2026 13:45:05 -0500 Subject: [PATCH 15/27] 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 | 227 +++++++++++++++++- 2 files changed, 233 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 d406b1c1560..6ab80d600b7 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, IndexBucketing}; 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; @@ -4262,6 +4264,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( @@ -4272,7 +4278,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 7d8fb3a8c7f..87af2028322 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -466,7 +466,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 @@ -488,6 +489,79 @@ impl LowLevelDriveOperation { processing_fee: op.cost(fee_version), ..Default::default() }), + CalculatedEphemeralCostOperation(cost, EphemeralPricing::DocumentTtl { + credit_per_byte, + lifetime_epochs, + }) => { + // The writes of a document whose type declares a `ttl`, + // priced exactly as in v0: each added byte costs the + // price of the document's remaining lifetime, as a + // storage fee paid out over the epochs it has left to + // live. Its elements carry no storage flags and this + // batch refunds nothing, so no fee history is needed: a + // sectioned removal here only counts bytes leaving the + // system. + let storage_fee = (cost.storage_cost.added_bytes as u64) + .checked_mul(credit_per_byte) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow pricing the bytes of a document with a time to live", + )))?; + let processing_fee = cost.ephemeral_cost(fee_version)?; + let lifetime_storage_fees = if storage_fee > 0 { + LifetimeStorageFees::from([(lifetime_epochs, storage_fee)]) + } else { + LifetimeStorageFees::new() + }; + let removed_bytes_from_system = + cost.storage_cost.removed_bytes.total_removed_bytes(); + Ok(FeeResult { + storage_fee, + processing_fee, + fee_refunds: FeeRefunds::default(), + removed_bytes_from_system, + lifetime_storage_fees, + }) + } + CalculatedEphemeralCostOperation(cost, EphemeralPricing::TimeRangeTtl) => { + // 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, + lifetime_storage_fees: Default::default(), + }) + } _ => { let cost = operation.operation_cost()?; // There is no need for a checked multiply here because added bytes are u64 and @@ -3325,6 +3399,157 @@ 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, + }, + EphemeralPricing::TimeRangeTtl, + ) + }; + + 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, + }, + EphemeralPricing::TimeRangeTtl, + ) + }; + 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(_))) + )); + } + } + + #[test] + fn should_price_document_ttl_bytes_as_v0_does_without_fee_history_in_v1() { + // A document with a time to live pays its bytes as a storage fee + // spread over its remaining epochs and refunds nothing, so the + // strict rule does not touch it: even a sectioned removal in its + // batch only counts bytes leaving the system. + let pricing = EphemeralPricing::DocumentTtl { + credit_per_byte: 7, + lifetime_epochs: 3, + }; + let document_ttl = || { + let mut removal = StorageRemovalPerEpochByIdentifier::default(); + removal.entry(OWNER).or_default().insert(3, 100); + LowLevelDriveOperation::CalculatedEphemeralCostOperation( + OperationCost { + seek_count: 2, + storage_cost: StorageCost { + added_bytes: 500, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::SectionedStorageRemoval(removal), + }, + storage_loaded_bytes: 10, + hash_node_calls: 1, + sinsemilla_hash_calls: 0, + }, + pricing, + ) + }; + + let v0_results = LowLevelDriveOperation::consume_to_fees_v0( + vec![document_ttl()], + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ) + .expect("v0 prices the bytes of a document with a time to live"); + let v1_results = LowLevelDriveOperation::consume_to_fees_v1( + vec![document_ttl()], + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ) + .expect("v1 prices them without a history"); + + assert_eq!(v0_results, v1_results); + assert_eq!(v1_results[0].storage_fee, 3_500); + assert_eq!( + v1_results[0].lifetime_storage_fees, + LifetimeStorageFees::from([(3, 3_500)]) + ); + assert_eq!(v1_results[0].removed_bytes_from_system, 100); + assert_eq!(v1_results[0].fee_refunds, FeeRefunds::default()); + } + /// 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 294a81daa388ba70d18cc9e25abe4e104da23096 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 22 Sep 2026 13:45:05 -0500 Subject: [PATCH 16/27] 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 | 76 ++++++++++++------------ packages/rs-drive/src/structure/tests.rs | 39 ++++++++---- 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 2bdcafa4e63..89eaede0709 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", @@ -5451,7 +5451,7 @@ }, "layer_shapes": { "contract_groups": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "00", "right": { @@ -5460,7 +5460,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": { @@ -5475,7 +5475,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": { @@ -5487,7 +5487,7 @@ } }, "contracts.contract": { - "origin": "fixture contracts_with_documents@14", + "origin": "fixture contracts_with_documents@15", "tree": { "hex": "01", "left": { @@ -5499,7 +5499,7 @@ } }, "contracts.contract.other": { - "origin": "fixture moderated_contract@14", + "origin": "fixture moderated_contract@15", "tree": { "hex": "80", "left": { @@ -5517,7 +5517,7 @@ } }, "contracts.contract.other.team_actions": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "58", "left": { @@ -5526,7 +5526,7 @@ } }, "contracts.contract.other.team_actions.active.team_action": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "53", "left": { @@ -5535,7 +5535,7 @@ } }, "contracts.contract.other.team_actions.closed.team_action": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "53", "left": { @@ -5544,7 +5544,7 @@ } }, "group_actions.contract.group": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "4d", "left": { @@ -5556,7 +5556,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": { @@ -5565,7 +5565,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": { @@ -5574,7 +5574,7 @@ } }, "identities.identity": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5599,7 +5599,7 @@ "states": [ { "state": "created", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5618,7 +5618,7 @@ }, { "state": "used_with_a_contract", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5640,7 +5640,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": { @@ -5666,7 +5666,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": { @@ -5675,7 +5675,7 @@ } }, "identities.identity.key_references": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "03", "left": { @@ -5687,7 +5687,7 @@ } }, "identities.identity.key_references.authentication": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "02", "left": { @@ -5702,7 +5702,7 @@ } }, "misc": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "45", "left": { @@ -5714,7 +5714,7 @@ } }, "pools.epoch": { - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5742,14 +5742,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": { @@ -5777,7 +5777,7 @@ }, { "state": "paid", - "origin": "fixture paid_epoch@14", + "origin": "fixture paid_epoch@15", "tree": { "hex": "74", "left": { @@ -5800,7 +5800,7 @@ ] }, "prefunded_balances": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5812,7 +5812,7 @@ } }, "root": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "40", "left": { @@ -5869,7 +5869,7 @@ } }, "saved_block_transactions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "65", "left": { @@ -5881,7 +5881,7 @@ } }, "shielded_balances.main_pool": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5899,7 +5899,7 @@ } }, "tokens": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5920,7 +5920,7 @@ } }, "tokens.distributions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5935,7 +5935,7 @@ } }, "tokens.distributions.perpetual.token": { - "origin": "fixture token_distributions_unclaimed@14", + "origin": "fixture token_distributions_unclaimed@15", "tree": { "hex": "c0", "left": { @@ -5944,7 +5944,7 @@ } }, "tokens.distributions.timed": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5956,7 +5956,7 @@ } }, "versions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "01", "left": { @@ -5965,7 +5965,7 @@ } }, "votes": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "64", "left": { @@ -5977,7 +5977,7 @@ } }, "votes.contested_resource": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "70", "left": { @@ -5986,7 +5986,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5995,7 +5995,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": { @@ -6004,7 +6004,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 e220f77517a..ba4138672ad 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"); @@ -280,7 +286,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; @@ -298,6 +304,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, @@ -1175,19 +1183,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 3dc7ce4e313d4e1b875618d2bd9b8030cd8bbbc9 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Wed, 23 Sep 2026 01:12:13 -0500 Subject: [PATCH 17/27] 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 | 439 ++++++++++++++++++ .../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, 1342 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 0fd298a4dc7..0cf967fa394 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 @@ -222,7 +222,7 @@ impl Drive { /// for them. Given the costs of a batch in which only a moderator's deletion's document /// operations free bytes: the document's, whoever paid for it, its owner or an earlier one, and /// those of any index subtree the deletion empties, whoever created it. -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..45a07ec2775 --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs @@ -0,0 +1,439 @@ +use crate::util::batch::DriveOperation; + +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; + +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 for the storage its document operations remove: the document's + /// own bytes, and those of any index subtree the deletion empties, whoever paid for them + /// (an earlier author's document may have created it). The bytes still leave the system, + /// but nobody gets them back, and the credits stay in the storage pools they were + /// distributed to. When the batch also frees moderation storage someone is owed + /// ([`DriveOperation::refunds_moderation_storage`]: a restored removal record replaced, + /// which may shrink, or the approvals and the info of a team action its closing approval + /// moves), the document operations are applied as a GroveDB batch of their own, after the + /// rest and in the same transaction, and only that batch forfeits: the moderators who paid + /// for those keep their refunds. Otherwise nothing but the document operations frees bytes + /// (a fresh record or approval is an insert, the nonce and the counts keep their size), and + /// the batch is applied as one, forfeiting whole. An estimate carries no refund to begin + /// with and prices the batch as it is applied: one GroveDB batch, or two when a forfeiting + /// deletion also frees moderation storage. + /// + /// Generation 2 (protocol version 15) is generation 1 with the owned transaction held + /// until the batch is priced. Everything generation 1 does carries over: a batch that + /// forfeits storage refunds refunds nobody, every write of one identity balance, fee pot + /// or prefunded specialized balance is merged into one, a batch writing one token balance + /// or supply twice is refused, and the debt the batch repaid + /// ([`LowLevelDriveOperation::RepaidIdentityDebt`]) is written to the processing fee pool + /// of the block's epoch after the batch applied and before the owned transaction commits, + /// so a pricing error drops the pool write with the rest. 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 { + DriveOperation::refuse_repeated_token_balance_writes(&operations)?; + let operations = DriveOperation::merge_balance_writes(operations)?; + if operations.is_empty() { + return Ok(FeeResult::default()); + } + let forfeits_storage_refunds = operations + .iter() + .any(DriveOperation::forfeits_storage_refunds); + // The document operations of a moderator's deletion go in a batch of their own only + // when something else in the batch may free bytes someone is owed. + let separates_forfeited_operations = forfeits_storage_refunds + && operations + .iter() + .any(DriveOperation::refunds_moderation_storage); + // 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()) + }; + + // The document operations of a moderator's deletion, whose removals refund nobody + let mut forfeited_low_level_operations = vec![]; + + 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); + } + + let forfeited = separates_forfeited_operations + && matches!(drive_op, DriveOperation::DocumentOperation(_)); + let mut converted = drive_op.into_low_level_drive_operations_after_ttl_drain( + self, + &mut estimated_costs_only_with_layer_info, + block_info, + transaction, + platform_version, + )?; + if forfeited { + forfeited_low_level_operations.append(&mut converted); + } else { + low_level_operations.append(&mut converted); + } + } + + let repaid_identity_debt = + LowLevelDriveOperation::take_repaid_identity_debt(&mut low_level_operations)?; + + let mut cost_operations = vec![]; + + let forfeited_estimated_costs_only_with_layer_info = + if forfeited_low_level_operations.is_empty() { + None + } else { + Some(estimated_costs_only_with_layer_info.clone()) + }; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + low_level_operations, + &mut cost_operations, + &platform_version.drive, + )?; + if let Some(estimated_costs_only_with_layer_info) = + forfeited_estimated_costs_only_with_layer_info + { + let mut forfeited_cost_operations = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + forfeited_low_level_operations, + &mut forfeited_cost_operations, + &platform_version.drive, + )?; + forfeit_storage_refunds(&mut forfeited_cost_operations); + cost_operations.append(&mut forfeited_cost_operations); + } else if forfeits_storage_refunds { + // One batch, in which only the document operations free bytes: it forfeits whole. + forfeit_storage_refunds(&mut cost_operations); + } + self.apply_repaid_identity_debt_to_processing_pool( + repaid_identity_debt, + &block_info.epoch, + transaction, + platform_version, + )?; + // 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 175efd4426b..801d3e5014e 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 e0182b59f2c..f9aba6618db 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_V3, // changed in v9: the end-date cleanup of ended contested vote polls removes an end date only once none of its polls remain - 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 ec5f8e724a3c4b70bb93901296d79d4b0bbba9d3 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Wed, 23 Sep 2026 01:12:13 -0500 Subject: [PATCH 18/27] 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 b1eb5cc3ac0..37579d9d4ef 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 @@ -1304,8 +1304,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 be2648f28d92f080497ee8eb6b0e8168790e7da4 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Thu, 24 Sep 2026 11:55:24 -0500 Subject: [PATCH 19/27] 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 d741971e812..901cab9dfae 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 f70b4dc14be..b6479bdd715 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 f9aba6618db..1a333189a71 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_V3, // changed in v9: the end-date cleanup of ended contested vote polls removes an end date only once none of its polls remain - 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 044853eadc443916a972928f593e4d00736b443d Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Thu, 24 Sep 2026 11:55:24 -0500 Subject: [PATCH 20/27] 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 | 169 +++++++++++++++++- 3 files changed, 285 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 44b4ffa0a6e..8a4e169b63a 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 @@ -278,10 +278,13 @@ 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 drive::drive::contract::moderation::types::ContractDocumentRemovalEntry; + use std::collections::BTreeMap; const YAPPR_CONTRACT_PATH: &str = "../rs-drive/tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json"; @@ -509,6 +512,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( @@ -519,7 +525,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 e4c97b77008..9907c5efdd1 100644 --- a/packages/rs-drive/src/drive/document/delete/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/mod.rs @@ -60,6 +60,7 @@ mod tests { use crate::config::DriveConfig; use crate::drive::document::tests::setup_dashpay; + use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; @@ -72,7 +73,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; @@ -854,6 +855,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); From aeb344ae1aabb6d4ee4e68bd2a702f7ef33f6f34 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 27 Sep 2026 03:41:35 -0500 Subject: [PATCH 21/27] fix(drive)!: price add_document, index-only deletes and warning replacements before committing Three fee-returning Drive writers still applied their batch, committing the transaction they own when the caller passes none, and priced it afterwards: add_document (whose override of an owner-flagged document can shrink it), the index-only document deletion wrapper (which removes owner-flagged index entries) and add_contract_warning (whose replacement of an existing entry can shrink it). From protocol version 15 pricing an owner-attributed storage removal without the fee history is an error, so a caller passing no history got the error after the write was durable. Each gets a generation 1 that starts the owned transaction itself, applies the batch through it, prices it and commits only once pricing succeeded. The operation builders are copied unchanged; the index-only operations builder stays at generation 0. Protocol version 15's document and contract method tables select the new generations, generation 0 is untouched for protocol version 14 and earlier. Co-Authored-By: Claude Fable 5.1 --- .../moderation/add_contract_warning/mod.rs | 27 ++- .../moderation/add_contract_warning/v1/mod.rs | 162 ++++++++++++++++++ .../mod.rs | 13 +- .../v1/mod.rs | 84 +++++++++ .../drive/document/insert/add_document/mod.rs | 13 +- .../document/insert/add_document/v1/mod.rs | 93 ++++++++++ .../drive_contract_method_versions/v5.rs | 8 +- .../drive_document_method_versions/v5.rs | 21 ++- .../src/version/drive_versions/v10.rs | 6 +- .../rs-platform-version/src/version/v15.rs | 2 +- 10 files changed, 409 insertions(+), 20 deletions(-) create mode 100644 packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/document/insert/add_document/v1/mod.rs diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs index 510e247286e..d2260723e1e 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -71,9 +72,20 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_contract_warning_v1( + contract_id, + identity_id, + warnings, + replaces_existing, + moderator_id, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contract_warning".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -131,9 +143,20 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_contract_warning_operations_v1( + contract_id, + identity_id, + warnings, + replaces_existing, + moderator_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contract_warning_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_warning/v1/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v1/mod.rs new file mode 100644 index 00000000000..9b8c595fde1 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v1/mod.rs @@ -0,0 +1,162 @@ +use crate::drive::contract::moderation::types::encode_warnings; +use crate::drive::contract::paths::contract_moderation_list_path; +use crate::drive::Drive; +use crate::error::drive::DriveError; +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, ContractWarning}; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +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, 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_contract_warning_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + warnings: &[ContractWarning], + 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_warning_operations_v1( + contract_id, + identity_id, + warnings, + 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 warning list entry is every warning the identity carries, oldest first, each its + /// block time and its reason (see [`encode_warnings`]), under the identity's id, flagged + /// with the moderator's identity so the storage refund on removal goes back to whoever + /// paid. A warn on an identity that already carries warnings replaces the entry in place + /// with one warning more; two operations on one key would fail the batch. The entry is + /// then longer, so its flags follow GroveDB's flag merge for a grown item: it passes, with + /// the refund of its removal, to the moderator that warned last, who pays for the bytes + /// the warning added. + /// + /// 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 the added warning, 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_warning_operations_v1( + &self, + contract_id: Identifier, + identity_id: Identifier, + warnings: &[ContractWarning], + 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::Warnings, + 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::Warnings), + identity_id.as_slice(), + Element::new_item_with_flags( + // Every reason was bounded by basic structure validation, so an encoding + // refusal is a code path that lost that guarantee, not a moderator's mistake. + encode_warnings(warnings).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a warning's reason is longer than a warning list entry can hold", + )) + })?, + 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/document/delete/delete_index_only_document_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs index 5f7098a5317..4618e003550 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -197,9 +198,19 @@ impl Drive { platform_version, previous_fee_versions, ), + 1 => self.delete_index_only_document_for_contract_v1( + document, + contract, + document_type, + block_info, + apply, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "delete_index_only_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_index_only_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v1/mod.rs new file mode 100644 index 00000000000..2348a927f56 --- /dev/null +++ b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v1/mod.rs @@ -0,0 +1,84 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +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::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 committed its owned transaction and priced 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. + #[allow(clippy::too_many_arguments)] + pub(super) fn delete_index_only_document_for_contract_v1( + &self, + document: Document, + contract: &DataContract, + document_type: DocumentTypeRef, + block_info: BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + let mut drive_operations: Vec = vec![]; + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + // With no caller transaction, TTL preparation (direct drainage + // writes) inside the operations builder and the apply below would + // each commit on their own: a tuple failing the row-commitment gate + // after preparation would leave drained buckets committed. Span + // both with one owned transaction. + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + let batch_operations = self.delete_index_only_document_for_contract_operations( + document, + contract, + document_type, + None, + &mut estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + platform_version, + )?; + + 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, + 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/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document/mod.rs index f118660eb6b..406e3a1defc 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::util::object_size_info::OwnedDocumentInfo; @@ -52,9 +53,19 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_document_v1( + owned_document_info, + data_contract_id, + document_type_name, + override_document, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_document".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/v1/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document/v1/mod.rs new file mode 100644 index 00000000000..eb10811f52a --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert/add_document/v1/mod.rs @@ -0,0 +1,93 @@ +use crate::drive::Drive; +use crate::error::document::DocumentError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; + +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +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, 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_v1( + &self, + owned_document_info: OwnedDocumentInfo, + data_contract_id: Identifier, + document_type_name: &str, + override_document: bool, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> 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 contract_fetch_info = self + .get_contract_with_fetch_info_and_add_to_operations( + data_contract_id.into_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; + + let document_type = contract.document_type_for_name(document_type_name)?; + + let document_and_contract_info = DocumentAndContractInfo { + owned_document_info, + contract, + document_type, + }; + 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, + None, + )?; + 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 166a85a0793..70a91d89325 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 @@ -11,9 +11,10 @@ use crate::version::drive_versions::drive_contract_method_versions::{ /// 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. +/// 0 -> 1 (its operation builder stays at 0), and the five moderation writers +/// `remove_contract_ban`, `remove_contract_suspension`, `remove_contract_warnings`, +/// `add_contract_suspension` and `add_contract_warning` (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 = @@ -31,6 +32,7 @@ pub const DRIVE_CONTRACT_METHOD_VERSIONS_V5: DriveContractMethodVersions = add_contract_suspension: 1, remove_contract_suspension: 1, remove_contract_warnings: 1, + add_contract_warning: 1, ..DRIVE_CONTRACT_METHOD_VERSIONS_V4.moderation }, ..DRIVE_CONTRACT_METHOD_VERSIONS_V4 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 index 549c6a05bb2..77c3896a77d 100644 --- 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 @@ -5,19 +5,21 @@ use crate::version::drive_versions::drive_document_method_versions::{ }; /// 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 +/// [`super::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4`], the eight fee-returning wrappers +/// (`add_document`, `add_document_for_contract`, `delete_document_for_contract`, +/// `delete_document_for_contract_id`, `delete_index_only_document_for_contract`, +/// `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: 1, add_document_for_contract: 1, ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.insert }, @@ -30,6 +32,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V5: DriveDocumentMethodVersions = delete: DriveDocumentDeleteMethodVersions { delete_document_for_contract: 1, delete_document_for_contract_id: 1, + delete_index_only_document_for_contract: 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 1a333189a71..21af220ede9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -42,9 +42,9 @@ use grovedb_version::version::v4::GROVE_V4; /// * **Price before commit** — `batch_operations.apply_drive_operations` /// 1 -> 2, `DRIVE_GROUP_METHOD_VERSIONS_V2` (`insert.add_group_action` /// 0 -> 1), `DRIVE_CONTRACT_METHOD_VERSIONS_V5` (`update_contract` 2 -> 3, -/// `apply_contract_with_serialization` 0 -> 1, the four moderation +/// `apply_contract_with_serialization` 0 -> 1, the five moderation /// writers that can free flagged bytes 0 -> 1) and -/// `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` (the six fee-returning document +/// `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` (the eight 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 @@ -80,7 +80,7 @@ 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_V5, // changed in v10: the six fee-returning document wrappers price before committing an owned transaction + document: DRIVE_DOCUMENT_METHOD_VERSIONS_V5, // changed in v10: the eight fee-returning document wrappers price before committing an owned transaction vote: DRIVE_VOTE_METHOD_VERSIONS_V3, // changed in v9: the end-date cleanup of ended contested vote polls removes an end date only once none of its polls remain 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 diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index c37dbaefa3b..ffae036d37c 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -49,7 +49,7 @@ 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, -/// the six document wrappers v1, `update_contract` v3, +/// the eight 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 From 11b73ded7676e99b27ab1fe5a2513cac8f6b257a Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 27 Sep 2026 03:41:35 -0500 Subject: [PATCH 22/27] test(drive): assert rejected add_document, index-only delete and warning pricing leaves state untouched Each of the three wrappers gets a test that inserts an owner-flagged element, makes the bare wrapper free flagged bytes without a fee history at the latest version, and checks the missing-history error comes back with the root hash and the stored element unchanged; the same call with the history (or a fresh insert) commits, and protocol version 14 still commits without one. Co-Authored-By: Claude Fable 5.1 --- .../v0/tests/index_only_e2e_tests.rs | 148 ++++++++++++++++++ .../src/drive/contract/moderation/tests.rs | 111 +++++++++++++ .../rs-drive/src/drive/document/insert/mod.rs | 142 ++++++++++++++++- 3 files changed, 400 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs index a27c91ccf7c..208589d8b63 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs @@ -3745,3 +3745,151 @@ fn untagged_like_skips_the_hashtag_index_and_prices_lower() { assert_grovedb_is_consistent(&drive); } + +/// Deleting an owner-flagged like frees the owner's bytes, 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 delete leaves every index entry and the root hash as +/// they were; with the history it refunds the owner and commits, and protocol version 14 +/// still deletes without one. +#[test] +fn should_leave_an_index_only_document_in_place_when_the_wrapper_cannot_price_its_removal() { + use crate::error::drive::DriveError; + use crate::error::Error; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::version::fee::FeeVersion; + use std::borrow::Cow; + use std::collections::BTreeMap; + + let insert_flagged_like = |drive: &Drive, contract: &DataContract, like: &Document, pv| { + let document_type = contract + .document_type_for_name(DOCTYPE) + .expect("like doctype exists"); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + like, + Some(Cow::Owned(StorageFlags::SingleEpochOwned(0, OWNER_1))), + )), + owner_id: None, + }, + contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + pv, + None, + ) + .expect("insert like with owned flags"); + }; + let root_hash = |drive: &Drive, pv: &PlatformVersion| { + drive + .grove + .root_hash(None, &pv.drive.grove_version) + .unwrap() + .expect("expected a root hash") + }; + + let pv = platform_version(); + let (drive, contract) = setup_likes(); + let document_type = contract + .document_type_for_name(DOCTYPE) + .expect("like doctype exists"); + let like = build_like(&contract, "dash", POST_A, OWNER_1, 1); + insert_flagged_like(&drive, &contract, &like, pv); + let before = root_hash(&drive, pv); + let mut by_post_level = doctype_path(&contract); + by_post_level.push(b"postId".to_vec()); + + let result = drive.delete_index_only_document_for_contract( + like.clone(), + &contract, + document_type, + BlockInfo::default(), + true, + None, + pv, + None, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "deleting owner-flagged entries without a fee history must be rejected, got {:?}", + result + ); + assert_eq!( + root_hash(&drive, pv), + before, + "a rejected delete must not persist" + ); + assert!( + read_grove_element(&drive, &by_post_level, &POST_A).is_some(), + "the like's group is still there" + ); + + let history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); + let fee_result = drive + .delete_index_only_document_for_contract( + like, + &contract, + document_type, + BlockInfo::default(), + true, + None, + pv, + Some(&history), + ) + .expect("expected to delete the like with the fee history"); + assert!(fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(Identifier::from(OWNER_1)) + .is_some()); + assert!( + read_grove_element(&drive, &by_post_level, &POST_A).is_none(), + "the delete was committed" + ); + assert_grovedb_is_consistent(&drive); + + // Protocol version 14 prices the shipped shortcut without a history and commits. + let frozen = PlatformVersion::get(14).expect("protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(frozen)); + let contract = json_document_to_contract( + "tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json", + false, + frozen, + ) + .expect("expected to parse the yappr-likes contract"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + frozen, + ) + .expect("expected to apply the yappr-likes contract"); + let document_type = contract + .document_type_for_name(DOCTYPE) + .expect("like doctype exists"); + let like = build_like(&contract, "dash", POST_A, OWNER_1, 1); + insert_flagged_like(&drive, &contract, &like, frozen); + drive + .delete_index_only_document_for_contract( + like, + &contract, + document_type, + BlockInfo::default(), + true, + None, + frozen, + None, + ) + .expect("protocol version 14 prices the shipped shortcut without a history"); +} diff --git a/packages/rs-drive/src/drive/contract/moderation/tests.rs b/packages/rs-drive/src/drive/contract/moderation/tests.rs index 8bd91a95946..f21bb5d86ae 100644 --- a/packages/rs-drive/src/drive/contract/moderation/tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/tests.rs @@ -1609,3 +1609,114 @@ fn should_leave_a_ban_in_place_when_the_bare_wrapper_cannot_price_its_removal() ContractModerationStatus::default(), ); } + +/// Rewriting an identity's warning entry frees the previous entry's moderator-flagged bytes, +/// 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 rewrite leaves the +/// entry and the root hash as they were; protocol version 14 still rewrites without one. +#[test] +fn should_leave_a_warning_in_place_when_the_bare_wrapper_cannot_price_its_replacement() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = moderated_contract_keeping(false, false, true); + insert(&drive, &contract, platform_version); + let contract_id = contract.id(); + let moderator = contract.owner_id(); + let target = identity(0x71); + // A long first entry, so replacing it with the short one below shrinks the stored bytes + // and frees moderator-flagged storage; a same-size or growing rewrite frees nothing. + let first = warning(1_000, &"first strike ".repeat(8)); + let second = warning(2_000, "cleared"); + let lists = [ContractModerationList::Warnings]; + + drive + .add_contract_warning( + contract_id, + target, + std::slice::from_ref(&first), + false, + moderator, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to warn"); + let before = root_hash(&drive, platform_version); + + let result = drive.add_contract_warning( + contract_id, + target, + std::slice::from_ref(&second), + true, + moderator, + &BlockInfo::default(), + true, + None, + platform_version, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "rewriting a flagged entry without a fee history must be rejected, got {:?}", + result + ); + assert_eq!( + root_hash(&drive, platform_version), + before, + "a rejected replacement must not persist" + ); + assert_status( + &drive, + contract_id, + target, + &lists, + warned_with(vec![first.clone()]), + ); + + // The production funnel carries the history and commits the rewrite. + apply_moderation( + &drive, + ContractModerationOperationType::AddWarning { + contract_id, + identity_id: target, + warnings: vec![second.clone()], + replaces_existing: true, + moderator_id: moderator, + }, + &BlockInfo::default(), + true, + platform_version, + ) + .expect("expected to replace the warning with the fee history"); + assert_status( + &drive, + contract_id, + target, + &lists, + warned_with(vec![second.clone()]), + ); + + // 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 = moderated_contract_keeping(false, false, true); + insert(&drive, &contract, frozen_platform_version); + for (warnings, replaces_existing) in [(vec![first], false), (vec![second], true)] { + drive + .add_contract_warning( + contract.id(), + target, + &warnings, + replaces_existing, + contract.owner_id(), + &BlockInfo::default(), + true, + None, + frozen_platform_version, + ) + .expect("protocol version 14 prices the shipped shortcut without a history"); + } +} diff --git a/packages/rs-drive/src/drive/document/insert/mod.rs b/packages/rs-drive/src/drive/document/insert/mod.rs index c70972cda78..7df8468c5b7 100644 --- a/packages/rs-drive/src/drive/document/insert/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/mod.rs @@ -51,6 +51,7 @@ mod tests { use rand::{random, Rng}; use crate::drive::document::tests::setup_dashpay; + use crate::drive::Drive; use crate::fees::op::LowLevelDriveOperation; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use crate::util::storage_flags::StorageFlags; @@ -68,10 +69,11 @@ mod tests { use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; - use dpp::document::{Document, DocumentV0Getters}; + use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; use dpp::fee::default_costs::KnownCostItem::StorageDiskUsageCreditPerByte; use dpp::fee::default_costs::{CachedEpochIndexFeeVersions, EpochCosts}; use dpp::fee::fee_result::FeeResult; + use dpp::platform_value::{Identifier, Value}; use dpp::tests::json_document::json_document_to_document; use dpp::version::fee::FeeVersion; use dpp::version::PlatformVersion; @@ -1545,4 +1547,142 @@ mod tests { "expected a document-type error, not DataContractNotFound: {err:?}" ); } + + /// Overriding an owner-flagged document rewrites its bytes, so pricing it without the + /// fee history is rejected from protocol version 15. `add_document` owns its + /// transaction when the caller passes none, so the rejected override leaves the stored + /// document and the root hash as they were; with the history it commits, and protocol + /// version 14 still commits without one. + #[test] + fn should_leave_a_document_in_place_when_add_document_cannot_price_its_override() { + 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/dashpay/dashpay-contract-all-mutable.json", + None, + None, + None::, + None, + Some(platform_version), + ); + let document_type = contract + .document_type_for_name("profile") + .expect("expected to get document type"); + let owner_id = random::<[u8; 32]>(); + let mut profile = json_document_to_document( + "tests/supporting_files/contract/dashpay/profile0.json", + Some(owner_id.into()), + document_type, + platform_version, + ) + .expect("expected to get document"); + // A long display name first, so the override below shrinks the stored bytes and + // frees owner-flagged storage. + profile.set("displayName", Value::Text("a".repeat(24))); + fn owned<'a>(document: &'a Document, owner_id: [u8; 32]) -> OwnedDocumentInfo<'a> { + OwnedDocumentInfo { + document_info: DocumentRefInfo(( + document, + Some(Cow::Owned(StorageFlags::SingleEpochOwned(0, owner_id))), + )), + owner_id: Some(owner_id), + } + } + drive + .add_document( + owned(&profile, owner_id), + contract.id(), + "profile", + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the profile"); + 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); + + // The override shrinks the flagged document, freeing owner-attributed bytes; the + // bare wrapper passes no history. + let mut replaced = profile.clone(); + replaced.set("displayName", Value::Text("a".to_string())); + let result = drive.add_document( + owned(&replaced, owner_id), + contract.id(), + "profile", + true, + &BlockInfo::default(), + true, + None, + platform_version, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "overriding owner-flagged bytes without a fee history must be rejected, got {:?}", + result + ); + assert_eq!( + root_hash(&drive), + before, + "a rejected override must not persist" + ); + + // The insert of a new document frees nothing and commits at the latest version. The + // profile type has a unique owner index, so the second profile needs its own owner. + let second_owner_id = random::<[u8; 32]>(); + let mut second = profile.clone(); + second.set_id(Identifier::from([0x55; 32])); + second.set_owner_id(Identifier::from(second_owner_id)); + drive + .add_document( + owned(&second, second_owner_id), + contract.id(), + "profile", + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert a second profile"); + assert_ne!(root_hash(&drive), before, "the insert 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/dashpay/dashpay-contract-all-mutable.json", + None, + None, + None::, + None, + Some(frozen_platform_version), + ); + for (document, override_document) in [(&profile, false), (&replaced, true)] { + drive + .add_document( + owned(document, owner_id), + contract.id(), + "profile", + override_document, + &BlockInfo::default(), + true, + None, + frozen_platform_version, + ) + .expect("protocol version 14 prices the shipped shortcut without a history"); + } + } } From b15064c78e0e32bc4e0115cdf58b8570fa6b49b6 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 27 Sep 2026 03:41:35 -0500 Subject: [PATCH 23/27] refactor(drive): take the repaid debt of a storage refund from the balance helper's outcome The recorded-owner refund primitive reconstructed the repaid part from the helper's balance field, reading it differently for zero and positive starting balances. The helper already reports the repaid debt; use it and derive the balance share by checked subtraction. Co-Authored-By: Claude Fable 5.1 --- .../v0/mod.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 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 435d1edc580..17f04884ce2 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 @@ -82,19 +82,15 @@ impl Drive { // 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 { + // whole refund is added. The helper reports the part that repaid debt, + // which lives outside the sum trees and is passed on for the caller's + // processing pool write; the rest reached the balance. + let owner_repaid_debt = outcome.repaid_debt(); + let reached_balance = credits - }; - let owner_repaid_debt = - credits - .checked_sub(reached_balance) + .checked_sub(owner_repaid_debt) .ok_or(ProtocolError::Overflow( - "a storage refund cannot raise a balance by more than the refund", + "storage refund debt repayment cannot exceed the refund", ))?; repaid_debt = From 76e85b83e8040de0733711ee89055af12e3c7f16 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 27 Sep 2026 05:29:20 -0500 Subject: [PATCH 24/27] fix(drive): price the contract fetch when adding a document by contract id add_document v1 (protocol version 15) inherited generation 0's second operations vector, declared after the contract lookup had recorded its read cost in the first one, so the fee never included the read. The new generation keeps one vector; generation 0 is unchanged. A test estimates the same insert through the contract-reference wrapper and the by-id wrapper: the processing fee is higher by the fetch at the latest version and equal at protocol version 14. Co-Authored-By: Claude Fable 5.1 --- .../document/insert/add_document/v1/mod.rs | 7 +- .../rs-drive/src/drive/document/insert/mod.rs | 84 ++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/rs-drive/src/drive/document/insert/add_document/v1/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document/v1/mod.rs index eb10811f52a..e5f2661f71c 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document/v1/mod.rs @@ -12,9 +12,11 @@ use dpp::version::PlatformVersion; use grovedb::TransactionArg; impl Drive { - /// Generation 1 differs from generation 0 in one thing: when the caller supplies no + /// Generation 1 differs from generation 0 in two things. 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. + /// And the contract fetch is priced with the write: generation 0 recorded the fetch in + /// one operations vector and priced another, so its fee never included the read. /// 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, a call passing @@ -65,7 +67,8 @@ impl Drive { contract, document_type, }; - let mut drive_operations: Vec = vec![]; + // The contract fetch above recorded its cost in `drive_operations`; generation 0 + // replaced the vector here and never priced that read. self.add_document_for_contract_apply_and_add_to_operations( document_and_contract_info, override_document, diff --git a/packages/rs-drive/src/drive/document/insert/mod.rs b/packages/rs-drive/src/drive/document/insert/mod.rs index 7df8468c5b7..54bbcc1e42c 100644 --- a/packages/rs-drive/src/drive/document/insert/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/mod.rs @@ -76,7 +76,7 @@ mod tests { use dpp::platform_value::{Identifier, Value}; use dpp::tests::json_document::json_document_to_document; use dpp::version::fee::FeeVersion; - use dpp::version::PlatformVersion; + use dpp::version::{PlatformVersion, LATEST_VERSION}; static EPOCH_CHANGE_FEE_VERSION_TEST: Lazy = Lazy::new(|| BTreeMap::from([(0, FeeVersion::first())])); @@ -1685,4 +1685,86 @@ mod tests { .expect("protocol version 14 prices the shipped shortcut without a history"); } } + + /// `add_document` looks the contract up by id and records that read's cost before + /// building the document operations. Generation 0 replaced its operations vector after + /// the lookup and priced the write alone; generation 1 prices the lookup with it, so its + /// fee exceeds the contract-reference wrapper's by the fetch cost at the latest version + /// and equals it at protocol version 14. + #[test] + fn should_price_the_contract_fetch_when_adding_a_document_by_contract_id() { + for (protocol_version, prices_the_fetch) in [(14, false), (LATEST_VERSION, true)] { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a platform version"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = setup_contract( + &drive, + "tests/supporting_files/contract/dashpay/dashpay-contract-all-mutable.json", + None, + None, + None::, + None, + Some(platform_version), + ); + let document_type = contract + .document_type_for_name("profile") + .expect("expected to get document type"); + let owner_id = random::<[u8; 32]>(); + let profile = json_document_to_document( + "tests/supporting_files/contract/dashpay/profile0.json", + Some(owner_id.into()), + document_type, + platform_version, + ) + .expect("expected to get document"); + let owned = || OwnedDocumentInfo { + document_info: DocumentRefInfo((&profile, StorageFlags::optional_default_as_cow())), + owner_id: Some(owner_id), + }; + + // Both estimates price the same write; only the by-id wrapper reads the contract. + let by_reference = drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: owned(), + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + false, + None, + platform_version, + None, + ) + .expect("expected to estimate the insert by contract reference"); + let by_id = drive + .add_document( + owned(), + contract.id(), + "profile", + false, + &BlockInfo::default(), + false, + None, + platform_version, + ) + .expect("expected to estimate the insert by contract id"); + + assert_eq!(by_id.storage_fee, by_reference.storage_fee); + if prices_the_fetch { + assert!( + by_id.processing_fee > by_reference.processing_fee, + "protocol version {protocol_version} must price the contract read: {} versus {}", + by_id.processing_fee, + by_reference.processing_fee + ); + } else { + assert_eq!( + by_id.processing_fee, by_reference.processing_fee, + "protocol version {protocol_version} keeps pricing the write alone" + ); + } + } + } } From d33de98738b12d110780bd7ecb35a89c1ccd1d7e Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 27 Sep 2026 09:53:58 -0500 Subject: [PATCH 25/27] fix(drive)!: price contested document inserts before committing The two contested-document wrappers applied their batch, committing the transaction they own when the caller passes none, and priced it afterwards. The second contender of a contest resolved without locking moves the poll's end date, which removes the creator-flagged join-window entry, so from protocol version 15 a call passing no fee history got the missing-history error after the write was durable. Each gets a generation 1 that starts the owned transaction itself, applies the batch through it, prices it and commits only once pricing succeeded. The by-id wrapper also keeps one operations vector so the contract read is priced with the write, as add_document v1 does. The operation builders are unchanged. Protocol version 15's document method table selects the new generations; generation 0 is untouched for protocol version 14 and earlier. Co-Authored-By: Claude Fable 5.1 --- .../add_contested_document/mod.rs | 13 ++- .../add_contested_document/v1/mod.rs | 106 ++++++++++++++++++ .../mod.rs | 13 ++- .../v1/mod.rs | 68 +++++++++++ .../drive_document_method_versions/v5.rs | 22 ++-- .../src/version/drive_versions/v10.rs | 4 +- .../rs-platform-version/src/version/v15.rs | 2 +- 7 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 packages/rs-drive/src/drive/document/insert_contested/add_contested_document/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract/v1/mod.rs diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document/mod.rs index e7b9454ae5f..b8368339ffd 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::util::object_size_info::OwnedDocumentInfo; @@ -59,9 +60,19 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_contested_document_v1( + owned_document_info, + contested_document_resource_vote_poll, + insert_without_check, + also_insert_vote_poll_stored_info, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contested_document".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document/v1/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document/v1/mod.rs new file mode 100644 index 00000000000..de667ff569a --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document/v1/mod.rs @@ -0,0 +1,106 @@ +use crate::drive::Drive; +use crate::error::document::DocumentError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::fee::fee_result::FeeResult; + +use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; +use grovedb::TransactionArg; + +impl Drive { + /// Generation 1 differs from generation 0 in two things. 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, a call passing + /// no history persisted the write and then failed: the second contender of a contest + /// resolved without locking removes the creator-flagged join-window end-date entry. It + /// now fails before anything is written. And the contract fetch is priced with the + /// write: generation 0 recorded the fetch in one operations vector and priced another. + /// With a caller transaction nothing is committed by Drive in either generation. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_contested_document_v1( + &self, + owned_document_info: OwnedDocumentInfo, + contested_document_resource_vote_poll: ContestedDocumentResourceVotePollWithContractInfo, + insert_without_check: bool, + also_insert_vote_poll_stored_info: Option, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> 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 contract_fetch_info = self + .get_contract_with_fetch_info_and_add_to_operations( + contested_document_resource_vote_poll + .contract + .id() + .into_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; + + let document_type = contract.document_type_for_name( + contested_document_resource_vote_poll + .document_type_name + .as_str(), + )?; + + let document_and_contract_info = DocumentAndContractInfo { + owned_document_info, + contract, + document_type, + }; + // The contract fetch above recorded its cost in `drive_operations`; generation 0 + // replaced the vector here and never priced that read. + self.add_contested_document_for_contract_apply_and_add_to_operations( + document_and_contract_info, + contested_document_resource_vote_poll, + insert_without_check, + block_info, + true, + apply, + also_insert_vote_poll_stored_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, + 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/document/insert_contested/add_contested_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract/mod.rs index ae6ae1395c3..0a962db2b52 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::util::object_size_info::DocumentAndContractInfo; @@ -57,9 +58,19 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_contested_document_for_contract_v1( + document_and_contract_info, + contested_document_resource_vote_poll, + insert_without_check, + block_info, + apply, + also_insert_vote_poll_stored_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contested_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_contested/add_contested_document_for_contract/v1/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract/v1/mod.rs new file mode 100644 index 00000000000..cb3f0f9c21c --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract/v1/mod.rs @@ -0,0 +1,68 @@ +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 crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; +use grovedb::TransactionArg; + +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, a call passing + /// no history persisted the write and then failed: the second contender of a contest + /// resolved without locking removes the creator-flagged join-window end-date entry. 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_contested_document_for_contract_v1( + &self, + document_and_contract_info: DocumentAndContractInfo, + contested_document_resource_vote_poll: ContestedDocumentResourceVotePollWithContractInfo, + insert_without_check: bool, + block_info: BlockInfo, + apply: bool, + also_insert_vote_poll_stored_info: 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 drive_operations: Vec = vec![]; + self.add_contested_document_for_contract_apply_and_add_to_operations( + document_and_contract_info, + contested_document_resource_vote_poll, + insert_without_check, + &block_info, + true, + apply, + also_insert_vote_poll_stored_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, + None, + )?; + 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_document_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs index 77c3896a77d..b1d12acd89c 100644 --- 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 @@ -1,21 +1,24 @@ 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, + DriveDocumentDeleteMethodVersions, DriveDocumentInsertContestedMethodVersions, + DriveDocumentInsertMethodVersions, DriveDocumentMethodVersions, + DriveDocumentUpdateMethodVersions, }; /// V5 is protocol version 15's document-method table. Relative to -/// [`super::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4`], the eight fee-returning wrappers -/// (`add_document`, `add_document_for_contract`, `delete_document_for_contract`, +/// [`super::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4`], the ten fee-returning wrappers +/// (`add_document`, `add_document_for_contract`, `add_contested_document`, +/// `add_contested_document_for_contract`, `delete_document_for_contract`, /// `delete_document_for_contract_id`, `delete_index_only_document_for_contract`, /// `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. +/// write before that error surfaced (a contested insert removes flagged bytes when the +/// second contender of a contest resolved without locking moves its end date). 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 { @@ -23,6 +26,11 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V5: DriveDocumentMethodVersions = add_document_for_contract: 1, ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.insert }, + insert_contested: DriveDocumentInsertContestedMethodVersions { + add_contested_document: 1, + add_contested_document_for_contract: 1, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.insert_contested + }, update: DriveDocumentUpdateMethodVersions { update_document_for_contract: 1, update_document_for_contract_id: 1, 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 21af220ede9..ffa56d23f64 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -44,7 +44,7 @@ use grovedb_version::version::v4::GROVE_V4; /// 0 -> 1), `DRIVE_CONTRACT_METHOD_VERSIONS_V5` (`update_contract` 2 -> 3, /// `apply_contract_with_serialization` 0 -> 1, the five moderation /// writers that can free flagged bytes 0 -> 1) and -/// `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` (the eight fee-returning document +/// `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` (the ten 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 @@ -80,7 +80,7 @@ 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_V5, // changed in v10: the eight fee-returning document wrappers price before committing an owned transaction + document: DRIVE_DOCUMENT_METHOD_VERSIONS_V5, // changed in v10: the ten fee-returning document wrappers price before committing an owned transaction vote: DRIVE_VOTE_METHOD_VERSIONS_V3, // changed in v9: the end-date cleanup of ended contested vote polls removes an end date only once none of its polls remain 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 diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index ffae036d37c..6fe692f79e7 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -49,7 +49,7 @@ 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, -/// the eight document wrappers v1, `update_contract` v3, +/// the ten 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 From 6d9c5cead27cf8d79313efe18b5140c9834135fe Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 27 Sep 2026 09:53:58 -0500 Subject: [PATCH 26/27] test(drive): assert a rejected second contender leaves a no-locking contest untouched Opens a contest resolved without locking with one contender, then joins a second through each bare wrapper without a fee history at the latest version: both are rejected with the root hash, the contenders and the end-date entries unchanged; the production funnel joins with the history and moves the end date to the vote window; protocol version 14 joins without one. A second test opens a contest through both wrappers at every version and checks the by-id fee includes the contract read from protocol version 15. The no-locking DPNS fixture is copied from the drive-abci tests. Co-Authored-By: Claude Fable 5.1 --- .../drive/document/insert_contested/mod.rs | 446 ++++++++++++++++++ ...act-contested-unique-index-no-locking.json | 169 +++++++ 2 files changed, 615 insertions(+) create mode 100644 packages/rs-drive/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json diff --git a/packages/rs-drive/src/drive/document/insert_contested/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/mod.rs index 425c37887b4..31e950520a5 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/mod.rs @@ -636,4 +636,450 @@ mod tests { } } } + + /// The second contender of a contest resolved without locking moves the poll's end + /// date: the creator-flagged join-window entry is removed and the vote-window entry + /// written, so pricing it without the fee history is rejected from protocol version + /// 15. Both bare wrappers own their transaction when the caller passes none, so the + /// rejected join leaves the root hash, the contenders and the end-date entries as they + /// were; the production funnel joins with the history, and protocol version 14 joins + /// without one. + mod second_contender_without_fee_history { + use super::*; + use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; + use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::query::vote_poll_vote_state_query::{ + ContestedDocumentVotePollDriveQueryResultType, + ResolvedContestedDocumentVotePollDriveQuery, + }; + use crate::query::VotePollsByEndDateDriveQuery; + use crate::util::batch::drive_op_batch::DocumentOperationType; + use crate::util::batch::DriveOperation; + use crate::util::object_size_info::{ + DataContractInfo, DataContractOwnedResolvedInfo, DocumentTypeInfo, + }; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::setup_contract; + use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use dpp::data_contract::DataContract; + use dpp::document::{Document, DocumentV0Setters}; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::fee::fee_result::FeeResult; + use dpp::identifier::Identifier; + use dpp::platform_value::{Bytes32, Value}; + use dpp::prelude::TimestampMillis; + use dpp::version::fee::FeeVersion; + use dpp::version::LATEST_VERSION; + use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; + use rand::rngs::StdRng; + use rand::SeedableRng; + use std::borrow::Cow; + use std::collections::BTreeMap; + + const NO_LOCKING_CONTRACT: &str = + "tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json"; + + fn quantum_domain_document( + contract: &DataContract, + owner_id: Identifier, + rng: &mut StdRng, + platform_version: &PlatformVersion, + ) -> Document { + let document_type = contract + .document_type_for_name("domain") + .expect("domain should exist on DPNS"); + let mut document = document_type + .random_document_with_params( + owner_id, + Bytes32::random_with_rng(rng), + Some(1), + Some(1), + Some(1), + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::MinDocumentFillSize, + rng, + platform_version, + ) + .expect("random document"); + document.set("parentDomainName", "dash".into()); + document.set("normalizedParentDomainName", "dash".into()); + document.set("label", "quantum".into()); + document.set("normalizedLabel", "quantum".into()); + document.set("records.identity", owner_id.into()); + document.set("subdomainRules.allowSubdomains", false.into()); + document + } + + fn vote_poll(contract: &DataContract) -> ContestedDocumentResourceVotePollWithContractInfo { + ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::OwnedDataContract(contract.clone()), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text("quantum".to_string()), + ], + } + } + + fn owned(document: &Document, owner_id: Identifier) -> OwnedDocumentInfo<'_> { + OwnedDocumentInfo { + document_info: DocumentRefInfo(( + document, + Some(Cow::Owned(StorageFlags::SingleEpochOwned( + 0, + owner_id.to_buffer(), + ))), + )), + owner_id: Some(owner_id.to_buffer()), + } + } + + struct Contest { + drive: Drive, + contract: DataContract, + first_owner_id: Identifier, + second_owner_id: Identifier, + second_document: Document, + } + + /// Opens the `dash.quantum` contest with one contender through the production + /// funnel, and prepares the second contender's document. + fn open_contest(platform_version: &PlatformVersion) -> Contest { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = setup_contract( + &drive, + NO_LOCKING_CONTRACT, + None, + None, + None::, + None, + Some(platform_version), + ); + let mut rng = StdRng::seed_from_u64(0x5EC0_004D); + let first_owner_id = Identifier::from([0x31; 32]); + let second_owner_id = Identifier::from([0x32; 32]); + let first_document = + quantum_domain_document(&contract, first_owner_id, &mut rng, platform_version); + let second_document = + quantum_domain_document(&contract, second_owner_id, &mut rng, platform_version); + drive + .add_contested_document_for_contract( + DocumentAndContractInfo { + owned_document_info: owned(&first_document, first_owner_id), + contract: &contract, + document_type: contract + .document_type_for_name("domain") + .expect("domain should exist on DPNS"), + }, + vote_poll(&contract), + false, + BlockInfo::default(), + true, + Some( + ContestedDocumentVotePollStoredInfo::new( + BlockInfo::default(), + platform_version, + ) + .expect("expected the stored info of a new poll"), + ), + None, + platform_version, + ) + .expect("expected to open the contest"); + Contest { + drive, + contract, + first_owner_id, + second_owner_id, + second_document, + } + } + + fn root_hash(drive: &Drive, platform_version: &PlatformVersion) -> [u8; 32] { + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected a root hash") + } + + fn end_dates(drive: &Drive, platform_version: &PlatformVersion) -> Vec { + VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(drive, None, &mut vec![], platform_version) + .expect("expected the end date entries") + .into_iter() + .flat_map(|(time, polls)| polls.into_iter().map(move |_| time)) + .collect() + } + + fn contenders( + drive: &Drive, + contract: &DataContract, + platform_version: &PlatformVersion, + ) -> Vec { + ResolvedContestedDocumentVotePollDriveQuery { + vote_poll: (&vote_poll(contract)).into(), + result_type: ContestedDocumentVotePollDriveQueryResultType::VoteTally, + offset: None, + limit: None, + start_at: None, + allow_include_locked_and_abstaining_vote_tally: false, + } + .execute(drive, None, &mut vec![], platform_version) + .expect("expected the contenders") + .contenders + .into_iter() + .map(|contender| contender.identity_id()) + .collect() + } + + fn fee_history() -> CachedEpochIndexFeeVersions { + BTreeMap::from([(0, FeeVersion::first())]) + } + + fn assert_rejected(result: Result) { + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "moving a creator-flagged end date without a fee history must be rejected, got {:?}", + result + ); + } + + #[test] + fn should_leave_a_no_locking_contest_in_place_when_a_bare_wrapper_cannot_price_the_second_contender( + ) { + let platform_version = PlatformVersion::latest(); + let Contest { + drive, + contract, + first_owner_id, + second_owner_id, + second_document, + } = open_contest(platform_version); + let join_window_end = end_dates(&drive, platform_version); + assert_eq!(join_window_end.len(), 1, "the contest has one end date"); + let before = root_hash(&drive, platform_version); + let document_type = contract + .document_type_for_name("domain") + .expect("domain should exist on DPNS"); + + // The wrapper taking the contract reference. + assert_rejected(drive.add_contested_document_for_contract( + DocumentAndContractInfo { + owned_document_info: owned(&second_document, second_owner_id), + contract: &contract, + document_type, + }, + vote_poll(&contract), + false, + BlockInfo::default(), + true, + None, + None, + platform_version, + )); + assert_eq!(root_hash(&drive, platform_version), before); + + // The wrapper taking the contract id. + assert_rejected(drive.add_contested_document( + owned(&second_document, second_owner_id), + vote_poll(&contract), + false, + None, + &BlockInfo::default(), + true, + None, + platform_version, + )); + assert_eq!( + root_hash(&drive, platform_version), + before, + "a rejected second contender must not persist" + ); + assert_eq!( + contenders(&drive, &contract, platform_version), + vec![first_owner_id], + "the contest still has its first contender only" + ); + assert_eq!( + end_dates(&drive, platform_version), + join_window_end, + "the join-window end date is still the only entry" + ); + + // The production funnel carries the history: the second contender joins and the + // end date moves to the vote window. + let history = fee_history(); + drive + .apply_drive_operations( + vec![DriveOperation::DocumentOperation( + DocumentOperationType::AddContestedDocument { + owned_document_info: owned(&second_document, second_owner_id), + contested_document_resource_vote_poll: vote_poll(&contract), + contract_info: DataContractInfo::BorrowedDataContract(&contract), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr("domain"), + insert_without_check: false, + also_insert_vote_poll_stored_info: None, + }, + )], + true, + &BlockInfo::default(), + None, + platform_version, + Some(&history), + ) + .expect("expected the second contender to join with the fee history"); + assert_eq!( + contenders(&drive, &contract, platform_version), + vec![first_owner_id, second_owner_id] + ); + let vote_window_end = end_dates(&drive, platform_version); + assert_eq!( + vote_window_end.len(), + 1, + "the contest still has one end date" + ); + assert!( + vote_window_end[0] > join_window_end[0], + "the end date moved from the join window to the vote window" + ); + + // Protocol version 14 prices the shipped shortcut without a history and commits. + let frozen_platform_version = PlatformVersion::get(14).expect("protocol version 14"); + let Contest { + drive, + contract, + first_owner_id, + second_owner_id, + second_document, + } = open_contest(frozen_platform_version); + drive + .add_contested_document( + owned(&second_document, second_owner_id), + vote_poll(&contract), + false, + None, + &BlockInfo::default(), + true, + None, + frozen_platform_version, + ) + .expect("protocol version 14 prices the shipped shortcut without a history"); + assert_eq!( + contenders(&drive, &contract, frozen_platform_version), + vec![first_owner_id, second_owner_id] + ); + } + + /// The first contender frees nothing, so the bare wrappers price it at every + /// version; the by-id wrapper's fee includes the contract read from protocol + /// version 15 (generation 0 priced the write alone). + #[test] + fn should_open_a_contest_through_the_bare_wrappers_at_every_version() { + for (protocol_version, prices_the_fetch) in [(14, false), (LATEST_VERSION, true)] { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a platform version"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = setup_contract( + &drive, + NO_LOCKING_CONTRACT, + None, + None, + None::, + None, + Some(platform_version), + ); + let mut rng = StdRng::seed_from_u64(0x5EC0_004E); + let owner_id = Identifier::from([0x33; 32]); + let document = + quantum_domain_document(&contract, owner_id, &mut rng, platform_version); + let stored_info = || { + Some( + ContestedDocumentVotePollStoredInfo::new( + BlockInfo::default(), + platform_version, + ) + .expect("expected the stored info of a new poll"), + ) + }; + + let by_reference = drive + .add_contested_document_for_contract( + DocumentAndContractInfo { + owned_document_info: owned(&document, owner_id), + contract: &contract, + document_type: contract + .document_type_for_name("domain") + .expect("domain should exist on DPNS"), + }, + vote_poll(&contract), + false, + BlockInfo::default(), + false, + stored_info(), + None, + platform_version, + ) + .expect("expected to estimate the contest by contract reference"); + let by_id = drive + .add_contested_document( + owned(&document, owner_id), + vote_poll(&contract), + false, + stored_info(), + &BlockInfo::default(), + false, + None, + platform_version, + ) + .expect("expected to estimate the contest by contract id"); + assert_eq!(by_id.storage_fee, by_reference.storage_fee); + if prices_the_fetch { + assert!( + by_id.processing_fee > by_reference.processing_fee, + "protocol version {protocol_version} must price the contract read" + ); + } else { + assert_eq!(by_id.processing_fee, by_reference.processing_fee); + } + + let before = root_hash(&drive, platform_version); + drive + .add_contested_document( + owned(&document, owner_id), + vote_poll(&contract), + false, + stored_info(), + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to open the contest by contract id"); + assert_ne!( + root_hash(&drive, platform_version), + before, + "the contest was committed" + ); + assert_eq!( + contenders(&drive, &contract, platform_version), + vec![owner_id] + ); + } + } + } } diff --git a/packages/rs-drive/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json b/packages/rs-drive/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json new file mode 100644 index 00000000000..db473f41d27 --- /dev/null +++ b/packages/rs-drive/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json @@ -0,0 +1,169 @@ +{ + "$formatVersion": "0", + "id": "DWBXe9EXFPHxvbArQgT45uQR5gMmi8dfMpLhR5KSbwnZ", + "ownerId": "2QjL594djCH2NyDsn45vd6yQjEDHupMKo7CEGVTHtQxU", + "version": 1, + "documentSchemas": { + "domain": { + "documentsMutable": false, + "canBeDeleted": true, + "transferable": 1, + "tradeMode": 1, + "type": "object", + "indices": [ + { + "name": "parentNameAndLabel", + "properties": [ + { + "normalizedParentDomainName": "asc" + }, + { + "normalizedLabel": "asc" + } + ], + "unique": true, + "contested": { + "fieldMatches": [ + { + "field": "normalizedLabel", + "regexPattern": "^[a-zA-Z01]{3,19}$" + } + ], + "resolution": 1, + "description": "If the normalized label part of this index is less than 20 characters (all alphabet a-z and 0 and 1) then this index is non unique while contest resolution takes place." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "asc" + } + ] + } + ], + "properties": { + "label": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", + "minLength": 3, + "maxLength": 63, + "position": 0, + "description": "Domain label. e.g. 'Bob'." + }, + "normalizedLabel": { + "type": "string", + "pattern": "^[a-hj-km-np-z0-9][a-hj-km-np-z0-9-]{0,61}[a-hj-km-np-z0-9]$", + "maxLength": 63, + "position": 1, + "description": "Domain label converted to lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'b0b'", + "$comment": "Must be equal to the label in lowercase. \"o\", \"i\" and \"l\" must be replaced with \"0\" and \"1\"." + }, + "parentDomainName": { + "type": "string", + "pattern": "^$|^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", + "minLength": 0, + "maxLength": 63, + "position": 2, + "description": "A full parent domain name. e.g. 'dash'." + }, + "normalizedParentDomainName": { + "type": "string", + "pattern": "^$|^[a-hj-km-np-z0-9][a-hj-km-np-z0-9-\\.]{0,61}[a-hj-km-np-z0-9]$", + "minLength": 0, + "maxLength": 63, + "position": 3, + "description": "A parent domain name in lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'dash'", + "$comment": "Must either be equal to an existing domain or empty to create a top level domain. \"o\", \"i\" and \"l\" must be replaced with \"0\" and \"1\". Only the data contract owner can create top level domains." + }, + "preorderSalt": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 4, + "description": "Salt used in the preorder document" + }, + "records": { + "type": "object", + "properties": { + "identity": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 1, + "contentMediaType": "application/x.dash.dpp.identifier", + "description": "Identifier name record that refers to an Identity" + } + }, + "minProperties": 1, + "position": 5, + "additionalProperties": false + }, + "subdomainRules": { + "type": "object", + "properties": { + "allowSubdomains": { + "type": "boolean", + "description": "This option defines who can create subdomains: true - anyone; false - only the domain owner", + "$comment": "Only the domain owner is allowed to create subdomains for non top-level domains", + "position": 0 + } + }, + "position": 6, + "description": "Subdomain rules allow domain owners to define rules for subdomains", + "additionalProperties": false, + "required": [ + "allowSubdomains" + ] + } + }, + "required": [ + "$createdAt", + "$updatedAt", + "$transferredAt", + "label", + "normalizedLabel", + "normalizedParentDomainName", + "preorderSalt", + "records", + "subdomainRules" + ], + "additionalProperties": false, + "$comment": "In order to register a domain you need to create a preorder. The preorder step is needed to prevent man-in-the-middle attacks. normalizedLabel + '.' + normalizedParentDomain must not be longer than 253 chars length as defined by RFC 1035. Domain documents are immutable: modification and deletion are restricted" + }, + "preorder": { + "documentsMutable": false, + "canBeDeleted": true, + "type": "object", + "indices": [ + { + "name": "saltedHash", + "properties": [ + { + "saltedDomainHash": "asc" + } + ], + "unique": true + } + ], + "properties": { + "saltedDomainHash": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 0, + "description": "Double sha-256 of the concatenation of a 32 byte random salt and a normalized domain name" + } + }, + "required": [ + "saltedDomainHash" + ], + "additionalProperties": false, + "$comment": "Preorder documents are immutable: modification and deletion are restricted" + } + } +} \ No newline at end of file From 324f10efcffe7c7df1608e3e8f243e1c01c81e14 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sun, 27 Sep 2026 09:53:58 -0500 Subject: [PATCH 27/27] test(drive): give the refund regression fixtures fixed owner ids The owner ids of the new price-before-commit regressions become persisted keys and storage flags, so a key-dependent failure would exercise different state on every retry. Use fixed, distinct identifiers instead of unseeded random ones. Co-Authored-By: Claude Fable 5.1 --- packages/rs-drive/src/drive/document/delete/mod.rs | 2 +- packages/rs-drive/src/drive/document/insert/mod.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive/src/drive/document/delete/mod.rs b/packages/rs-drive/src/drive/document/delete/mod.rs index 9907c5efdd1..fab848346cf 100644 --- a/packages/rs-drive/src/drive/document/delete/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/mod.rs @@ -876,7 +876,7 @@ mod tests { 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 owner_id = [0x44; 32]; let person_document = json_document_to_document( "tests/supporting_files/contract/family/person0.json", Some(owner_id.into()), diff --git a/packages/rs-drive/src/drive/document/insert/mod.rs b/packages/rs-drive/src/drive/document/insert/mod.rs index 54bbcc1e42c..e2f909b813b 100644 --- a/packages/rs-drive/src/drive/document/insert/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/mod.rs @@ -1569,7 +1569,7 @@ mod tests { let document_type = contract .document_type_for_name("profile") .expect("expected to get document type"); - let owner_id = random::<[u8; 32]>(); + let owner_id = [0x41; 32]; let mut profile = json_document_to_document( "tests/supporting_files/contract/dashpay/profile0.json", Some(owner_id.into()), @@ -1640,7 +1640,7 @@ mod tests { // The insert of a new document frees nothing and commits at the latest version. The // profile type has a unique owner index, so the second profile needs its own owner. - let second_owner_id = random::<[u8; 32]>(); + let second_owner_id = [0x42; 32]; let mut second = profile.clone(); second.set_id(Identifier::from([0x55; 32])); second.set_owner_id(Identifier::from(second_owner_id)); @@ -1709,7 +1709,7 @@ mod tests { let document_type = contract .document_type_for_name("profile") .expect("expected to get document type"); - let owner_id = random::<[u8; 32]>(); + let owner_id = [0x43; 32]; let profile = json_document_to_document( "tests/supporting_files/contract/dashpay/profile0.json", Some(owner_id.into()),