diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 6dc8a66b8d5..6d2e2469c6b 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -49,8 +49,9 @@ in `FeeStorageVersion`: | `storage_seek_cost` | 2,000 | Cost of a single disk seek | Storage fees are **refundable**: when data is deleted, a portion of the original -storage fee is returned to the identity that paid it (see [Refunds](#refunds) -below). +storage fee becomes a refund for the owner recorded in the stored bytes' storage +flags, which is not always the identity that paid the fee (see +[Refunds](#refunds) below). ### Processing Fees @@ -450,6 +451,38 @@ 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. 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 Fees do not go directly to the block proposer. Instead, they accumulate in @@ -523,6 +556,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 | diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs index 2f6d53cc0d3..3b3bd873ae3 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs @@ -265,9 +265,12 @@ mod tests { use dpp::dashcore::Network; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::identifier::Identifier; use dpp::platform_value::Value; use dpp::tests::json_document::json_document_to_contract; + use dpp::version::fee::FeeVersion; + use std::collections::BTreeMap; const YAPPR_CONTRACT_PATH: &str = "../rs-drive/tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json"; @@ -480,6 +483,9 @@ mod tests { fn should_report_a_deleted_post_of_a_deletable_document_join() { let (platform, state, version, contract) = setup_yappr_state_at(YAPPR_DELETABLE_POSTS_CONTRACT_PATH); + // The post is owner-flagged, so pricing its removal needs the fee history of the + // removing block, as every production caller passes. + let fee_history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); platform .drive .delete_document_for_contract( @@ -490,7 +496,7 @@ mod tests { true, None, version, - None, + Some(&fee_history), ) .expect("expected to delete the post"); diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 343665de9b8..16521adbf8f 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "latest_protocol_version": 14, + "latest_protocol_version": 15, "element_kinds": [ { "name": "Item", @@ -5096,7 +5096,7 @@ }, "layer_shapes": { "contract_groups": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "00", "right": { @@ -5105,7 +5105,7 @@ } }, "contract_groups.groups.group": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "02", "left": { @@ -5120,7 +5120,7 @@ } }, "contract_groups.members.contract": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "01", "left": { @@ -5132,7 +5132,7 @@ } }, "contracts.contract": { - "origin": "fixture contracts_with_documents@14", + "origin": "fixture contracts_with_documents@15", "tree": { "hex": "01", "left": { @@ -5144,7 +5144,7 @@ } }, "contracts.contract.other": { - "origin": "fixture moderated_contract@14", + "origin": "fixture moderated_contract@15", "tree": { "hex": "80", "left": { @@ -5162,7 +5162,7 @@ } }, "group_actions.contract.group": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "4d", "left": { @@ -5174,7 +5174,7 @@ } }, "group_actions.contract.group.active.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "53", "left": { @@ -5183,7 +5183,7 @@ } }, "group_actions.contract.group.closed.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "53", "left": { @@ -5192,7 +5192,7 @@ } }, "identities.identity": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5217,7 +5217,7 @@ "states": [ { "state": "created", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5236,7 +5236,7 @@ }, { "state": "used_with_a_contract", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5258,7 +5258,7 @@ }, { "state": "budgeted_key_and_contract", - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5284,7 +5284,7 @@ ] }, "identities.identity.contract_info.bound": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "01", "left": { @@ -5293,7 +5293,7 @@ } }, "identities.identity.key_references": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "03", "left": { @@ -5305,7 +5305,7 @@ } }, "identities.identity.key_references.authentication": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "02", "left": { @@ -5320,7 +5320,7 @@ } }, "misc": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "54", "left": { @@ -5329,7 +5329,7 @@ } }, "pools.epoch": { - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5357,14 +5357,14 @@ "states": [ { "state": "future", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "73" } }, { "state": "running", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5392,7 +5392,7 @@ }, { "state": "paid", - "origin": "fixture paid_epoch@14", + "origin": "fixture paid_epoch@15", "tree": { "hex": "74", "left": { @@ -5415,7 +5415,7 @@ ] }, "prefunded_balances": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5427,7 +5427,7 @@ } }, "root": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "40", "left": { @@ -5484,7 +5484,7 @@ } }, "saved_block_transactions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "65", "left": { @@ -5496,7 +5496,7 @@ } }, "shielded_balances.main_pool": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5514,7 +5514,7 @@ } }, "tokens": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5535,7 +5535,7 @@ } }, "tokens.distributions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5550,7 +5550,7 @@ } }, "tokens.distributions.perpetual.token": { - "origin": "fixture token_distributions_unclaimed@14", + "origin": "fixture token_distributions_unclaimed@15", "tree": { "hex": "c0", "left": { @@ -5559,7 +5559,7 @@ } }, "tokens.distributions.timed": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5571,7 +5571,7 @@ } }, "versions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "01", "left": { @@ -5580,7 +5580,7 @@ } }, "votes": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "64", "left": { @@ -5592,7 +5592,7 @@ } }, "votes.contested_resource": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "70", "left": { @@ -5601,7 +5601,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5610,7 +5610,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type.indexes.value.contender": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5619,7 +5619,7 @@ } }, "withdrawals": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "03", "left": { diff --git a/packages/rs-drive/src/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/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs index 1a1112d1a89..6313f97fc58 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/ranked_index_e2e_tests.rs @@ -67,12 +67,15 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::platform_value::Value; use dpp::prelude::DataContract; use dpp::tests::json_document::json_document_to_contract; +use dpp::version::fee::FeeVersion; use dpp::version::PlatformVersion; use grovedb::element::indexed::AVG_FIXED_POINT_SCALE; use grovedb::Element; +use std::collections::BTreeMap; /// The one index property every doctype in the fixture ranks by. const GROUP_PROPERTY: &str = "restaurantId"; @@ -1331,6 +1334,10 @@ fn estimated_and_actual_update_fees( .document_type_for_name(document_type_name) .unwrap_or_else(|_| panic!("{document_type_name} doctype exists")); let storage_flags = Some(Cow::Owned(StorageFlags::SingleEpoch(0))); + // The replaced element carries epoch flags, so pricing its removal needs + // the fee history of the removing block, as every production caller + // passes. + let fee_history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); let run = |apply: bool| { drive @@ -1344,7 +1351,7 @@ fn estimated_and_actual_update_fees( storage_flags.clone(), None, pv, - None, + Some(&fee_history), ) .unwrap_or_else(|e| { panic!("expected the {document_type_name} update (apply={apply}) to succeed: {e}") diff --git a/packages/rs-drive/src/drive/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 + ); + } +} 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/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/document_removal_tests.rs b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs index 127c5ac2fba..9b03de2e9c0 100644 --- a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs @@ -1041,8 +1041,11 @@ fn should_keep_refunding_a_batch_without_the_forfeiture_before_protocol_version_ .apply_drive_operations, 0 ); + // Protocol version 14 introduced generation 1 and is frozen at it; later versions may + // select later generations, which carry the forfeiture forward. assert_eq!( - PlatformVersion::latest() + PlatformVersion::get(14) + .expect("expected protocol version 14") .drive .methods .batch_operations diff --git a/packages/rs-drive/src/drive/contract/moderation/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/contract/moderation/tests.rs b/packages/rs-drive/src/drive/contract/moderation/tests.rs index 611a942210b..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,10 @@ 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; 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 +21,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 +98,121 @@ 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, + ) +} + +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( + 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 +449,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 +555,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 +644,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 +795,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 +856,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 +889,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 +934,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 +965,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 +996,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); @@ -1133,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 @@ -1310,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/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/delete/mod.rs b/packages/rs-drive/src/drive/document/delete/mod.rs index e04dc6eb7f7..c7629bfa5af 100644 --- a/packages/rs-drive/src/drive/document/delete/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/mod.rs @@ -74,7 +74,7 @@ mod tests { use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; - use dpp::document::Document; + use dpp::document::{Document, DocumentV0Getters}; use dpp::fee::default_costs::KnownCostItem::StorageDiskUsageCreditPerByte; use dpp::fee::default_costs::{CachedEpochIndexFeeVersions, EpochCosts}; use dpp::identifier::Identifier; @@ -858,6 +858,172 @@ mod tests { .expect("expected to be able to delete the document"); } + /// Deleting an owner-flagged document without the fee history is rejected from + /// protocol version 15, and the public wrapper, owning its transaction when the caller + /// passes none, rejects it before anything is written: the document is still there and + /// the root hash is unchanged. Protocol version 14 still deletes it. + #[test] + fn should_leave_a_document_in_place_when_the_wrapper_cannot_price_its_removal() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + + let contract = setup_contract( + &drive, + "tests/supporting_files/contract/family/family-contract-reduced.json", + None, + None, + None::, + None, + Some(platform_version), + ); + let document_type = contract + .document_type_for_name("person") + .expect("expected to get document type"); + let owner_id = rand::thread_rng().gen::<[u8; 32]>(); + let person_document = json_document_to_document( + "tests/supporting_files/contract/family/person0.json", + Some(owner_id.into()), + document_type, + platform_version, + ) + .expect("expected to get document"); + let storage_flags = Some(Cow::Owned(StorageFlags::SingleEpochOwned(0, owner_id))); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&person_document, storage_flags)), + owner_id: Some(owner_id), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("expected to insert a document successfully"); + let root_hash = |drive: &Drive| { + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected a root hash") + }; + let before = root_hash(&drive); + + let result = drive.delete_document_for_contract( + person_document.id(), + &contract, + "person", + BlockInfo::default(), + true, + None, + platform_version, + None, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "deleting owner-flagged bytes without a fee history must be rejected, got {:?}", + result + ); + assert_eq!( + root_hash(&drive), + before, + "a rejected delete must not persist" + ); + let query = DriveDocumentQuery::from_sql_expr( + "select * from person where firstName = 'Samuel' order by firstName asc limit 100", + &contract, + Some(&DriveConfig::default()), + platform_version, + ) + .expect("should build query"); + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("expected to execute query"); + assert_eq!(results.len(), 1, "the document is still there"); + + // The same delete with the block's history is priced, refunds the owner and commits. + let fee_result = drive + .delete_document_for_contract( + person_document.id(), + &contract, + "person", + BlockInfo::default(), + true, + None, + platform_version, + Some(&EPOCH_CHANGE_FEE_VERSION_TEST), + ) + .expect("expected to delete the document with the fee history"); + assert!(fee_result.fee_refunds.get(&owner_id).is_some()); + let (results, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version) + .expect("expected to execute query"); + assert!(results.is_empty(), "the delete was committed"); + + // Protocol version 14 prices the shipped shortcut without a history and commits. + let frozen_platform_version = PlatformVersion::get(14).expect("protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(frozen_platform_version)); + let contract = setup_contract( + &drive, + "tests/supporting_files/contract/family/family-contract-reduced.json", + None, + None, + None::, + None, + Some(frozen_platform_version), + ); + let document_type = contract + .document_type_for_name("person") + .expect("expected to get document type"); + let person_document = json_document_to_document( + "tests/supporting_files/contract/family/person0.json", + Some(owner_id.into()), + document_type, + frozen_platform_version, + ) + .expect("expected to get document"); + let storage_flags = Some(Cow::Owned(StorageFlags::SingleEpochOwned(0, owner_id))); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&person_document, storage_flags)), + owner_id: Some(owner_id), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + frozen_platform_version, + None, + ) + .expect("expected to insert a document successfully"); + drive + .delete_document_for_contract( + person_document.id(), + &contract, + "person", + BlockInfo::default(), + true, + None, + frozen_platform_version, + None, + ) + .expect("protocol version 14 prices the shipped shortcut without a history"); + } + #[test] fn test_delete_dashpay_documents() { let drive = setup_drive_with_initial_state_structure(None); diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs index 28310f15ada..5637a36715d 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::util::object_size_info::DocumentAndContractInfo; @@ -54,9 +55,18 @@ impl Drive { platform_version, previous_fee_versions, ), + 1 => self.add_document_for_contract_v1( + document_and_contract_info, + override_document, + block_info, + apply, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_document_for_contract".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs index 5deaf230552..0fd12a4dbf6 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract/time_range_index_e2e_tests.rs @@ -27,9 +27,11 @@ use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::DataContractFactory; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::FeeResult; use dpp::platform_value::{platform_value, Identifier, Value}; use dpp::prelude::DataContract; +use dpp::version::fee::FeeVersion; use dpp::version::PlatformVersion; use std::borrow::Cow; use std::collections::BTreeMap; @@ -4236,6 +4238,10 @@ fn ttl_index_bytes_bill_to_processing_without_refunds() { ); let doc_id = make_doc().id(); + // The standing twin's index bytes carry owner flags, so pricing their + // removal needs the fee history of the removing block, as every + // production caller passes. + let fee_history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); let delete = |contract: &DataContract| -> FeeResult { drive .delete_document_for_contract( @@ -4246,7 +4252,7 @@ fn ttl_index_bytes_bill_to_processing_without_refunds() { true, None, platform_version, - None, + Some(&fee_history), ) .expect("delete document") }; diff --git a/packages/rs-drive/src/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/mod.rs b/packages/rs-drive/src/drive/document/update/mod.rs index 5f4addfe4d6..ba62c8413f5 100644 --- a/packages/rs-drive/src/drive/document/update/mod.rs +++ b/packages/rs-drive/src/drive/document/update/mod.rs @@ -3476,7 +3476,7 @@ mod tests { storage_flags, None, platform_version, - None, + Some(&EPOCH_CHANGE_FEE_VERSION_TEST), ) .expect("key-changing update on aggregate index must succeed"); 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-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/drive/group/mod.rs b/packages/rs-drive/src/drive/group/mod.rs index 9dfb73d09ee..5ab181fb192 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,173 @@ 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 + ); + // 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(); + 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_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); 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..6fe378b6e46 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/mod.rs @@ -0,0 +1,84 @@ +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 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 + /// 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 + /// + /// * `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, 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( + &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..435d1edc580 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/update/methods/credit_storage_refunds_to_owners_operations/v0/mod.rs @@ -0,0 +1,805 @@ +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::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::TransactionArg; +use std::collections::BTreeMap; + +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 repaid_debt: Credits = 0; + 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 let Some(existing_balance) = existing_balance { + // 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, + 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 + .checked_add(owner_repaid_debt) + .ok_or(ProtocolError::Overflow( + "storage refund credits repaying identity debt overflow", + ))?; + + 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( + "storage refund credits routed to the processing pool overflow", + ), + )?; + } + } + + Ok(StorageRefundCreditOutcome { + credited, + repaid_debt, + routed_to_processing_pool, + }) + } +} + +#[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; + + /// Refund credits per storage epoch, per owner. + type RefundsByOwner<'a> = [([u8; 32], &'a [(u16, Credits)])]; + + 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 + } + + /// 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 { + 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)]), + repaid_debt: 0, + 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)]), + repaid_debt: 0, + 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 + .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"); + + 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_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_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); + 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 + ); + } +} diff --git a/packages/rs-drive/src/drive/identity/update/methods/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/mod.rs index ffbf378f148..8dbb3da6aaf 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/mod.rs @@ -4,6 +4,7 @@ mod add_new_unique_keys_to_identity; mod add_to_identity_balance; mod add_to_previous_balance; mod apply_balance_change_from_fee_to_identity; +mod credit_storage_refunds_to_owners_operations; mod disable_identity_keys; pub(crate) mod merge_identity_nonce; mod re_enable_identity_keys; diff --git a/packages/rs-drive/src/drive/identity/update/structs/mod.rs b/packages/rs-drive/src/drive/identity/update/structs/mod.rs index dbdb9a86d2f..71b93a3715f 100644 --- a/packages/rs-drive/src/drive/identity/update/structs/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/structs/mod.rs @@ -2,3 +2,5 @@ pub mod add_to_previous_balance_outcome; /// Applying a balance chance outcome pub mod apply_balance_change_outcome; +/// The outcome of crediting storage refunds to their recorded owners +pub mod storage_refund_credit_outcome; diff --git a/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs b/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs new file mode 100644 index 00000000000..b80f1d38cab --- /dev/null +++ b/packages/rs-drive/src/drive/identity/update/structs/storage_refund_credit_outcome/mod.rs @@ -0,0 +1,48 @@ +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, 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 `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 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. + pub routed_to_processing_pool: Credits, +} + +impl StorageRefundCreditOutcome { + /// 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.processing_pool_share()?, |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..fafa1b185bc 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,11 +48,114 @@ 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, })), } } } + +#[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/calculate_fee/v1/mod.rs b/packages/rs-drive/src/fees/calculate_fee/v1/mod.rs new file mode 100644 index 00000000000..4201dc023bd --- /dev/null +++ b/packages/rs-drive/src/fees/calculate_fee/v1/mod.rs @@ -0,0 +1,54 @@ +use crate::drive::Drive; +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::fees::op::{BaseOp, LowLevelDriveOperation}; +use dpp::block::epoch::Epoch; +use dpp::fee::fee_result::FeeResult; + +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use enum_map::EnumMap; +use platform_version::version::fee::FeeVersion; + +impl Drive { + /// Calculates fees for the given operations. Returns the storage and processing costs. + /// + /// Generation 1: storage refunds are priced through `consume_to_fees_v1`, + /// which requires and consults the fee history for every owner-attributed + /// storage removal. Selected by drive table v10 (protocol version 15). + #[inline(always)] + pub(crate) fn calculate_fee_v1( + base_operations: Option>, + drive_operations: Option>, + epoch: &Epoch, + epochs_per_era: u16, + fee_version: &FeeVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + let mut aggregate_fee_result = FeeResult::default(); + if let Some(base_operations) = base_operations { + for (base_op, count) in base_operations.iter() { + match base_op.cost().checked_mul(*count) { + None => return Err(Error::Fee(FeeError::Overflow("overflow error"))), + Some(cost) => match aggregate_fee_result.processing_fee.checked_add(cost) { + None => return Err(Error::Fee(FeeError::Overflow("overflow error"))), + Some(value) => aggregate_fee_result.processing_fee = value, + }, + } + } + } + + if let Some(drive_operations) = drive_operations { + for drive_fee_result in LowLevelDriveOperation::consume_to_fees_v1( + drive_operations, + epoch, + epochs_per_era, + fee_version, + previous_fee_versions, + )? { + aggregate_fee_result.checked_add_assign(drive_fee_result)?; + } + } + + Ok(aggregate_fee_result) + } +} diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index e501c02f8b2..62be629fd12 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -368,6 +368,118 @@ 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 (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 + /// 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() + }), + CalculatedEphemeralCostOperation(cost) => { + // TTL'd-subtree bytes: the added bytes bill to + // PROCESSING at the ephemeral rate instead of to + // storage, exactly as in v0. TTL elements carry no + // storage flags, so their removal can only ever be + // basic and needs no fee history; a sectioned removal + // here is a corrupted batch, not a missing history. + let ephemeral_bytes_fee = (cost.storage_cost.added_bytes as u64) + .checked_mul( + fee_version + .storage + .ttl_ephemeral_disk_usage_credit_per_byte, + ) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow pricing ephemeral bytes", + )))?; + let processing_fee = cost + .ephemeral_cost(fee_version)? + .checked_add(ephemeral_bytes_fee) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow adding ephemeral bytes fee", + )))?; + let removed_bytes_from_system = match cost.storage_cost.removed_bytes { + NoStorageRemoval => 0, + BasicStorageRemoval(amount) => amount, + SectionedStorageRemoval(_) => { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "TTL'd subtrees carry no storage flags, so an ephemeral \ + batch cannot produce sectioned (refundable) removal", + ))) + } + }; + Ok(FeeResult { + storage_fee: 0, + processing_fee, + fee_refunds: FeeRefunds::default(), + removed_bytes_from_system, + }) + } + _ => { + let cost = operation.operation_cost()?; + // There is no need for a checked multiply here because added bytes are u64 and + // storage disk usage credit per byte should never be high enough to cause an overflow + let storage_fee = cost.storage_cost.added_bytes as u64 + * fee_version.storage.storage_disk_usage_credit_per_byte; + let processing_fee = cost.ephemeral_cost(fee_version)?; + let (fee_refunds, removed_bytes_from_system) = + match cost.storage_cost.removed_bytes { + NoStorageRemoval => (FeeRefunds::default(), 0), + BasicStorageRemoval(amount) => { + // this is not always considered an error + (FeeRefunds::default(), amount) + } + SectionedStorageRemoval(mut removal_per_epoch_by_identifier) => { + let system_amount = removal_per_epoch_by_identifier + .remove(&Identifier::default()) + .map_or(0, |a| a.values().sum()); + let previous_fee_versions = previous_fee_versions.ok_or( + Error::Drive(DriveError::CorruptedCodeExecution( + "a storage refund needs the fee history of the block that removes the bytes", + )), + )?; + ( + FeeRefunds::from_storage_removal( + removal_per_epoch_by_identifier, + epoch.index, + epochs_per_era, + previous_fee_versions, + )?, + system_amount, + ) + } + }; + Ok(FeeResult { + storage_fee, + processing_fee, + fee_refunds, + removed_bytes_from_system, + }) + } + }) + .collect() + } + /// Returns the cost of this operation pub fn operation_cost(self) -> Result { match self { @@ -2816,4 +2928,397 @@ 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]; + + /// 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". + 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: &BytesByOwner) -> 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: &BytesByOwner = &[ + (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); + } + } + } + + #[test] + fn should_price_ephemeral_bytes_to_processing_without_fee_history_in_v1() { + // TTL'd index bytes bill to processing at the ephemeral rate and + // their removal is never sectioned, so the arm is the same in both + // generations and needs no history even under the strict rule. + let ephemeral = |added: u32, removed: u32| { + LowLevelDriveOperation::CalculatedEphemeralCostOperation(OperationCost { + seek_count: 2, + storage_cost: StorageCost { + added_bytes: added, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::BasicStorageRemoval(removed), + }, + storage_loaded_bytes: 10, + hash_node_calls: 1, + sinsemilla_hash_calls: 0, + }) + }; + + let v0_results = LowLevelDriveOperation::consume_to_fees_v0( + vec![ephemeral(500, 40)], + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ) + .expect("v0 prices ephemeral bytes"); + let v1_results = LowLevelDriveOperation::consume_to_fees_v1( + vec![ephemeral(500, 40)], + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + None, + ) + .expect("v1 prices ephemeral bytes without a history"); + + assert_eq!(v0_results, v1_results); + assert_eq!( + v1_results[0].storage_fee, 0, + "TTL'd bytes never bill storage" + ); + assert_eq!( + v1_results[0].processing_fee, + ephemeral(500, 40) + .operation_cost() + .expect("cost") + .ephemeral_cost(&FEE_VERSION1) + .expect("ephemeral cost") + + 500 + * FEE_VERSION1 + .storage + .ttl_ephemeral_disk_usage_credit_per_byte + ); + assert_eq!(v1_results[0].removed_bytes_from_system, 40); + assert_eq!(v1_results[0].fee_refunds, FeeRefunds::default()); + + // A sectioned removal inside an ephemeral batch is corrupted state in + // both generations, with or without a history. + let corrupted = || { + let mut removal = StorageRemovalPerEpochByIdentifier::default(); + removal.entry(OWNER).or_default().insert(3, 100); + LowLevelDriveOperation::CalculatedEphemeralCostOperation(OperationCost { + seek_count: 0, + storage_cost: StorageCost { + added_bytes: 0, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::SectionedStorageRemoval(removal), + }, + storage_loaded_bytes: 0, + hash_node_calls: 0, + sinsemilla_hash_calls: 0, + }) + }; + let history: CachedEpochIndexFeeVersions = + BTreeMap::from([(0u16, FeeVersion::first())]); + for previous_fee_versions in [None, Some(&history)] { + assert!(matches!( + LowLevelDriveOperation::consume_to_fees_v1( + vec![corrupted()], + &epoch(5), + DEFAULT_EPOCHS_PER_ERA, + &FEE_VERSION1, + previous_fee_versions, + ), + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + )); + } + } + + /// The premise of the equality above. A shipped schedule that kept + /// fee version number 1 but changed its storage rates would make v0 + /// (which prices number 1 at the first generation) and v1 (which + /// 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 + ); + } + } + } } diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 6a1889e289c..8ece29320e2 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -119,7 +119,13 @@ fn should_record_a_contract_layer_with_its_documents_on_top() { // fixture. Documents are read most and sit at the root of the layer; the // contract itself and everything else hang below. let contract = &json["layer_shapes"]["contracts.contract"]; - assert_eq!(contract["origin"], "fixture contracts_with_documents@14"); + assert_eq!( + contract["origin"], + format!( + "fixture contracts_with_documents@{}", + PlatformVersion::latest().protocol_version + ) + ); assert_eq!(contract["tree"]["hex"], "01"); assert_eq!(contract["tree"]["left"]["hex"], "00"); assert_eq!(contract["tree"]["right"]["hex"], "02"); @@ -279,7 +285,7 @@ mod fixtures { use crate::structure::shape::shape_at; use crate::structure::{KeySpec, NodeId}; use crate::util::batch::drive_op_batch::{ - AddressFundsOperationType, ContractFeePotOperationType, + AddressFundsOperationType, ContractFeePotOperationType, GroupOperationType, }; use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; use crate::util::batch::ContractModerationOperationType; @@ -297,6 +303,8 @@ mod fixtures { use crate::drive::credit_pools::epochs::paths::EpochProposers; use dpp::block::epoch::Epoch; use dpp::block::finalized_epoch_info::v0::FinalizedEpochInfoV0; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::version::fee::FeeVersion; use dpp::contract_group::{ generate_contract_group_id, ContractGroupMember, ContractGroupMembership, ContractGroupRegistration, @@ -1041,19 +1049,30 @@ mod fixtures { ) .expect("expected to propose the action"); if close { + // Closing moves signer-flagged items, so pricing the removal + // needs the fee history of the removing block; production + // closes actions through `apply_drive_operations`, which + // forwards it. + let fee_history: CachedEpochIndexFeeVersions = + BTreeMap::from([(0, FeeVersion::first())]); drive - .add_group_action( - contract.id(), - 0, - None, + .apply_drive_operations( + vec![DriveOperation::GroupOperation( + GroupOperationType::AddGroupAction { + contract_id: contract.id(), + group_contract_position: 0, + initialize_with_insert_action_info: None, + action_id, + signer_identity_id: member_2, + signer_power: 2, + closes_group_action: true, + }, + )], true, - action_id, - member_2, - 2, &BlockInfo::default(), - true, None, platform_version, + Some(&fee_history), ) .expect("expected to close the action"); } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs index 27b98d4a88c..f90f2131499 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::util::batch::DriveOperation; @@ -64,9 +65,17 @@ impl Drive { platform_version, previous_fee_versions, ), + 2 => self.apply_drive_operations_v2( + operations, + apply, + block_info, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "apply_drive_operations".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs index 72993a46739..5dceda04dda 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs @@ -144,7 +144,7 @@ impl Drive { /// for them. The whole batch, which is exact: a moderator's deletion removes the document and /// nothing else, since its removal record is written once (a document id is produced at most /// once) and the nonce it bumps keeps its size. -fn forfeit_storage_refunds(cost_operations: &mut [LowLevelDriveOperation]) { +pub(super) fn forfeit_storage_refunds(cost_operations: &mut [LowLevelDriveOperation]) { for operation in cost_operations.iter_mut() { let LowLevelDriveOperation::CalculatedCostOperation(cost) = operation else { continue; diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs new file mode 100644 index 00000000000..776609fe052 --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v2/mod.rs @@ -0,0 +1,378 @@ +use crate::util::batch::DriveOperation; + +use crate::drive::Drive; +use crate::error::Error; + +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; + +use grovedb::{EstimatedLayerInformation, TransactionArg}; + +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; + +use crate::util::batch::drive_op_batch::drive_methods::apply_drive_operations::v1::forfeit_storage_refunds; +use crate::util::batch::drive_op_batch::finalize_task::{ + DriveOperationFinalizationTasks, DriveOperationFinalizeTask, +}; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use std::collections::HashMap; + +impl Drive { + /// Applies a list of high level DriveOperations to the drive, and calculates the fee for them. + /// + /// # Arguments + /// + /// * `operations` - A vector of `DriveOperation`s to apply to the drive. + /// * `apply` - A boolean flag indicating whether to apply the changes or only estimate costs. + /// * `block_info` - A reference to information about the current block. + /// * `transaction` - Transaction arguments. + /// + /// # Returns + /// + /// Returns a `Result` containing the `FeeResult` if the operations are successfully applied, + /// otherwise an `Error`. + /// + /// If `apply` is set to true, it applies the low-level drive operations and updates side info accordingly. + /// If not, it only estimates the costs and updates estimated costs with layer info. + /// + /// Generation 1 (protocol version 14) is generation 0, and a batch that carries a storage + /// refund forfeiture ([`DriveOperation::forfeits_storage_refunds`], a moderator's document + /// deletion) refunds nobody: the bytes it removes still leave the system, but whoever paid + /// for them gets nothing back, and the credits stay in the storage pools they were + /// distributed to. An estimate carries no refund to begin with, so `check_tx` sees the + /// same fee with or without the forfeiture. + /// + /// Generation 2 (protocol version 15) is generation 1 with the owned transaction held + /// until the batch is priced. From protocol version 15 pricing an owner-attributed storage + /// removal without the fee history is an error; generation 1 committed its owned + /// transaction before pricing, so a caller passing no transaction and no history had its + /// writes persisted and the error returned. Now `Drive::calculate_fee` runs first, a + /// pricing error drops the owned transaction with everything it wrote, and the finalize + /// tasks still run after the commit. With a caller transaction nothing is committed by + /// Drive in either generation. + #[inline(always)] + pub(crate) fn apply_drive_operations_v2( + &self, + operations: Vec, + apply: bool, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + if operations.is_empty() { + return Ok(FeeResult::default()); + } + let forfeits_storage_refunds = operations + .iter() + .any(DriveOperation::forfeits_storage_refunds); + // With no caller transaction, TTL preparation (direct drainage + // writes), conversion reads, and the batch apply would each commit + // on their own, so a conversion error after preparation would leave + // drained buckets committed without the write. Span all of it with + // one owned transaction and commit only once the batch applied. + let caller_transaction = transaction; + let owned_transaction = + (apply && transaction.is_none()).then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(caller_transaction); + if apply { + self.prepare_drive_operations_time_range_ttl( + &operations, + block_info, + transaction, + platform_version, + )?; + } + let mut low_level_operations = vec![]; + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let mut finalize_tasks: Vec = Vec::new(); + + for drive_op in operations { + if let Some(tasks) = drive_op.finalization_tasks(platform_version)? { + finalize_tasks.extend(tasks); + } + + low_level_operations.append( + &mut drive_op.into_low_level_drive_operations_after_ttl_drain( + self, + &mut estimated_costs_only_with_layer_info, + block_info, + transaction, + platform_version, + )?, + ); + } + + let mut cost_operations = vec![]; + + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + low_level_operations, + &mut cost_operations, + &platform_version.drive, + )?; + if forfeits_storage_refunds { + forfeit_storage_refunds(&mut cost_operations); + } + + // Price before committing: a pricing error drops the owned transaction with + // everything it wrote, so nothing is persisted without its fee result. + let fee_result = Drive::calculate_fee( + None, + Some(cost_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + )?; + + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, &platform_version.drive)?; + } + + // Execute drive operation callbacks after updating state. Nothing was written when + // only estimating, so there is nothing to finalize. The tasks read through the + // caller's transaction; an owned one was committed just above, and `caller_transaction` + // is `None` exactly then, so they read committed state. + if apply { + for task in finalize_tasks { + task.execute(self, caller_transaction, platform_version)?; + } + } + + Ok(fee_result) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::util::batch::drive_op_batch::GroupOperationType; + use crate::util::batch::DriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::group::v0::GroupV0; + use dpp::data_contract::group::Group; + use dpp::data_contract::v1::DataContractV1; + use dpp::data_contract::DataContract; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::group::action_event::GroupActionEvent; + use dpp::group::group_action::v0::GroupActionV0; + use dpp::group::group_action::GroupAction; + use dpp::group::group_action_status::GroupActionStatus; + use dpp::identifier::Identifier; + use dpp::tokens::token_event::TokenEvent; + use dpp::version::fee::FeeVersion; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + /// A contract with one two-member group and an action the first member opened, + /// so closing the action moves signer-flagged items: the one removal a batch can + /// carry without a document fixture. + fn drive_with_open_group_action( + platform_version: &PlatformVersion, + ) -> (Drive, Identifier, Identifier, Identifier) { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let member_1 = Identifier::from([1; 32]); + let member_2 = Identifier::from([2; 32]); + let contract = DataContract::V1(DataContractV1 { + id: Identifier::from([3; 32]), + version: 0, + owner_id: member_1, + document_types: Default::default(), + config: DataContractConfig::V0(DataContractConfigV0 { + can_be_deleted: false, + readonly: false, + keeps_history: false, + documents_keep_history_contract_default: false, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: None, + requires_identity_decryption_bounded_key: None, + }), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: BTreeMap::from([( + 0, + Group::V0(GroupV0 { + members: [(member_1, 1), (member_2, 2)].into(), + required_power: 3, + }), + )]), + tokens: BTreeMap::from([( + 0, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + )]), + keywords: Vec::new(), + description: None, + }); + let contract_id = contract.id(); + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the contract"); + let action_id = Identifier::from([4; 32]); + let action = GroupAction::V0(GroupActionV0 { + contract_id, + proposer_id: member_1, + token_contract_position: 0, + event: GroupActionEvent::TokenEvent(TokenEvent::Mint(100, member_1, None)), + }); + drive + .add_group_action( + contract_id, + 0, + Some(action), + false, + action_id, + member_1, + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to open the action"); + (drive, contract_id, member_2, action_id) + } + + fn close_action( + contract_id: Identifier, + member_2: Identifier, + action_id: Identifier, + ) -> Vec> { + vec![DriveOperation::GroupOperation( + GroupOperationType::AddGroupAction { + contract_id, + group_contract_position: 0, + initialize_with_insert_action_info: None, + action_id, + signer_identity_id: member_2, + signer_power: 2, + closes_group_action: true, + }, + )] + } + + fn is_closed( + drive: &Drive, + contract_id: Identifier, + action_id: Identifier, + platform_version: &PlatformVersion, + ) -> bool { + drive + .fetch_action_is_closed( + contract_id, + 0, + action_id, + true, + None, + &mut vec![], + platform_version, + ) + .expect("expected to check if the action is closed") + } + + #[test] + fn should_not_commit_an_owned_transaction_when_the_batch_cannot_be_priced() { + // No caller transaction and no fee history: closing the action frees + // signer-flagged bytes, which the latest generation refuses to price. The + // batch was applied inside an owned transaction that is dropped with the + // error, so the action is still open afterwards. + let platform_version = PlatformVersion::latest(); + let (drive, contract_id, member_2, action_id) = + drive_with_open_group_action(platform_version); + + let result = drive.apply_drive_operations( + close_action(contract_id, member_2, action_id), + true, + &BlockInfo::default(), + None, + platform_version, + None, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "pricing a flagged removal without the fee history must fail, got {:?}", + result + ); + assert!( + !is_closed(&drive, contract_id, action_id, platform_version), + "a batch that could not be priced must not be committed" + ); + + // The same batch with the block's history is priced and committed. + let history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]); + let fee_result = drive + .apply_drive_operations( + close_action(contract_id, member_2, action_id), + true, + &BlockInfo::default(), + None, + platform_version, + Some(&history), + ) + .expect("expected to close the action with the fee history"); + assert!(fee_result.fee_refunds.get(&[1; 32]).is_some()); + assert!(is_closed(&drive, contract_id, action_id, platform_version)); + let closed_signers = drive + .fetch_action_signers( + contract_id, + 0, + GroupActionStatus::ActionClosed, + action_id, + None, + platform_version, + ) + .expect("expected the closed signers"); + assert_eq!(closed_signers.len(), 2); + } + + #[test] + fn should_price_and_commit_without_history_under_the_frozen_generation() { + // Protocol version 14 selects generation 1, which commits before pricing and + // prices fee version number 1 without a history: the close persists. + let platform_version = PlatformVersion::get(14).expect("protocol version 14"); + let (drive, contract_id, member_2, action_id) = + drive_with_open_group_action(platform_version); + + drive + .apply_drive_operations( + close_action(contract_id, member_2, action_id), + true, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("the frozen generation prices the shipped shortcut without a history"); + assert!(is_closed(&drive, contract_id, action_id, platform_version)); + } +} diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs index d6d2c930e18..4d04c936415 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs @@ -4,6 +4,7 @@ pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; #[derive(Clone, Debug, Default)] pub struct DriveContractMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs new file mode 100644 index 00000000000..166a85a0793 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v5.rs @@ -0,0 +1,37 @@ +use crate::version::drive_versions::drive_contract_method_versions::v4::DRIVE_CONTRACT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_contract_method_versions::{ + DriveContractApplyMethodVersions, DriveContractMethodVersions, + DriveContractModerationMethodVersions, DriveContractUpdateMethodVersions, +}; + +/// Drive contract methods for protocol version 15. +/// +/// 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, + 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_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index 0465a5a7368..319f2b14a24 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -4,6 +4,7 @@ pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; #[derive(Clone, Debug, Default)] pub struct DriveDocumentMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs new file mode 100644 index 00000000000..549c6a05bb2 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs @@ -0,0 +1,36 @@ +use crate::version::drive_versions::drive_document_method_versions::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_document_method_versions::{ + DriveDocumentDeleteMethodVersions, DriveDocumentInsertMethodVersions, + DriveDocumentMethodVersions, DriveDocumentUpdateMethodVersions, +}; + +/// V5 is protocol version 15's document-method table. Relative to +/// [`super::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4`], the six fee-returning wrappers +/// (`add_document_for_contract`, `delete_document_for_contract`, +/// `delete_document_for_contract_id`, `update_document_for_contract`, +/// `update_document_for_contract_id` and `update_document_with_serialization_for_contract`) +/// are bumped to `1`: when the caller passes no transaction they write and price inside one +/// owned transaction and commit it only once `Drive::calculate_fee` succeeded. Pricing an +/// owner-attributed storage removal without the fee history is an error from this version, +/// and generation 0 committed the write before that error surfaced. The operation builders +/// and the `_apply_and_add_to_operations` methods every production caller uses through +/// `apply_drive_operations` are unchanged. +pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V5: DriveDocumentMethodVersions = + DriveDocumentMethodVersions { + insert: DriveDocumentInsertMethodVersions { + add_document_for_contract: 1, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.insert + }, + update: DriveDocumentUpdateMethodVersions { + update_document_for_contract: 1, + update_document_for_contract_id: 1, + update_document_with_serialization_for_contract: 1, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.update + }, + delete: DriveDocumentDeleteMethodVersions { + delete_document_for_contract: 1, + delete_document_for_contract_id: 1, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.delete + }, + ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4 + }; diff --git a/packages/rs-platform-version/src/version/drive_versions/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/drive_identity_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs index 7b99529799e..e23b58ce75b 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/mod.rs @@ -2,6 +2,7 @@ use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Default)] pub struct DriveIdentityMethodVersions { @@ -239,4 +240,9 @@ pub struct DriveIdentityUpdateMethodVersions { /// Rewrites a key with a raised total budget or a later expiry (protocol version 14). Keys /// cannot carry limits before v14, so earlier tables keep the slot `None`. pub update_identity_key_limits: OptionalFeatureVersion, + /// Read by `Drive::credit_storage_refunds_to_owners_operations`: credits each recorded + /// owner of a storage refund and reports the amount whose owner has no balance element + /// so the caller can route it to the current epoch's processing pool. `None` before + /// protocol version 15, where no path settles refunds outside a state transition. + pub credit_storage_refunds_to_owners: OptionalFeatureVersion, } diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs index c2e4ba4b933..6e2614ad8cf 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v1.rs @@ -119,6 +119,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V1: DriveIdentityMethodVersions = remove_from_identity_balance: 0, refresh_identity_key_reference_operations: 0, update_identity_key_limits: None, + credit_storage_refunds_to_owners: None, }, insert: DriveIdentityInsertMethodVersions { add_new_identity: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs index 0040c073c06..55330459c33 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs @@ -165,6 +165,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = remove_from_identity_balance: 0, refresh_identity_key_reference_operations: 0, update_identity_key_limits: Some(0), + credit_storage_refunds_to_owners: None, }, insert: DriveIdentityInsertMethodVersions { add_new_identity: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v3.rs new file mode 100644 index 00000000000..c95284d9aee --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v3.rs @@ -0,0 +1,188 @@ +use crate::version::drive_versions::drive_identity_method_versions::{ + DriveIdentityContractInfoMethodVersions, DriveIdentityCostEstimationMethodVersions, + DriveIdentityFetchAttributesMethodVersions, DriveIdentityFetchFullIdentityMethodVersions, + DriveIdentityFetchMethodVersions, DriveIdentityFetchPartialIdentityMethodVersions, + DriveIdentityFetchPublicKeyHashesMethodVersions, DriveIdentityInsertMethodVersions, + DriveIdentityKeyHashesToIdentityInsertMethodVersions, DriveIdentityKeysBudgetMethodVersions, + DriveIdentityKeysFetchMethodVersions, DriveIdentityKeysInsertMethodVersions, + DriveIdentityKeysMethodVersions, DriveIdentityKeysProveMethodVersions, + DriveIdentityMethodVersions, DriveIdentityProveMethodVersions, + DriveIdentityUpdateMethodVersions, DriveIdentityWithdrawalDocumentMethodVersions, + DriveIdentityWithdrawalMethodVersions, DriveIdentityWithdrawalTransactionIndexMethodVersions, + DriveIdentityWithdrawalTransactionMethodVersions, + DriveIdentityWithdrawalTransactionQueueMethodVersions, +}; + +/// V3 is protocol version 15's identity-method table. It differs from V2 in +/// one slot: +/// +/// * `update.credit_storage_refunds_to_owners` `None -> Some(0)`: the +/// primitive that credits each recorded owner of a storage refund and +/// reports the amount whose owner has no balance element, so a block +/// lifecycle path (or, later, the state transition refund path) can route +/// that amount to the current epoch's processing pool instead of halting. +/// No key or permission is consulted on any route: a frozen but existing +/// owner is credited, an owner without a balance is settled into the pool. +/// Nothing before v15 settles refunds outside a state transition, so V1 and +/// V2 keep the slot `None`. +pub const DRIVE_IDENTITY_METHOD_VERSIONS_V3: DriveIdentityMethodVersions = + DriveIdentityMethodVersions { + fetch: DriveIdentityFetchMethodVersions { + public_key_hashes: DriveIdentityFetchPublicKeyHashesMethodVersions { + fetch_full_identities_by_unique_public_key_hashes: 0, + fetch_full_identity_by_unique_public_key_hash: 0, + fetch_identity_id_by_unique_public_key_hash: 0, + fetch_identity_ids_by_non_unique_public_key_hash: 0, + fetch_identity_ids_by_unique_public_key_hashes: 0, + fetch_serialized_full_identity_by_unique_public_key_hash: 0, + has_any_of_unique_public_key_hashes: 0, + has_non_unique_public_key_hash: 0, + has_non_unique_public_key_hash_already_for_identity: 0, + has_unique_public_key_hash: 0, + fetch_full_identity_by_non_unique_public_key_hash: 0, + }, + attributes: DriveIdentityFetchAttributesMethodVersions { + revision: 0, + nonce: 0, + identity_contract_nonce: 0, + balance: 0, + balance_include_debt: 0, + negative_balance: 0, + }, + partial_identity: DriveIdentityFetchPartialIdentityMethodVersions { + fetch_identity_revision_with_keys: 0, + fetch_identity_balance_with_keys: 0, + fetch_identity_balance_with_keys_and_revision: 0, + fetch_identity_with_balance: 0, + fetch_identity_keys: 0, + }, + full_identity: DriveIdentityFetchFullIdentityMethodVersions { + fetch_full_identity: Some(0), + fetch_full_identities: Some(0), + }, + }, + prove: DriveIdentityProveMethodVersions { + full_identity: 0, + full_identities: 0, + identity_nonce: 0, + identity_contract_nonce: 0, + identities_contract_keys: 0, + prove_full_identities_by_unique_public_key_hashes: 0, + prove_full_identity_by_unique_public_key_hash: 0, + prove_identity_id_by_unique_public_key_hash: 0, + prove_identity_ids_by_unique_public_key_hashes: 0, + prove_full_identity_by_non_unique_public_key_hash: 0, + }, + keys: DriveIdentityKeysMethodVersions { + fetch: DriveIdentityKeysFetchMethodVersions { + fetch_all_current_identity_keys: 0, + fetch_all_identity_keys: 0, + fetch_identities_all_keys: 0, + fetch_identity_keys: 0, + fetch_identities_contract_keys: 0, + }, + prove: DriveIdentityKeysProveMethodVersions { + prove_identities_all_keys: 0, + prove_identity_keys: 0, + }, + insert: DriveIdentityKeysInsertMethodVersions { + create_key_tree_with_keys: 0, + create_new_identity_key_query_trees: 0, + insert_key_searchable_references: 0, + insert_key_to_storage: 0, + insert_new_non_unique_key: 1, + insert_new_unique_key: 1, + replace_key_in_storage: 0, + }, + insert_key_hash_identity_reference: + DriveIdentityKeyHashesToIdentityInsertMethodVersions { + add_estimation_costs_for_insert_non_unique_public_key_hash_reference: 0, + add_estimation_costs_for_insert_unique_public_key_hash_reference: 0, + insert_non_unique_public_key_hash_reference_to_identity: 0, + insert_reference_to_non_unique_key: 0, + insert_reference_to_unique_key: 0, + insert_unique_public_key_hash_reference_to_identity: 0, + }, + budget: DriveIdentityKeysBudgetMethodVersions { + insert_identity_key_budget: Some(0), + fetch_identity_key_remaining_budget: Some(0), + deduct_from_identity_key_budget: Some(0), + add_estimation_costs_for_key_budgets: Some(0), + fetch_identity_keys_remaining_budgets: Some(0), + prove_identity_keys_remaining_budgets: Some(0), + add_to_identity_key_budget: Some(0), + }, + }, + update: DriveIdentityUpdateMethodVersions { + update_identity_revision: 0, + merge_identity_nonce: 0, + update_identity_negative_credit_operation: 0, + initialize_identity_revision: 0, + disable_identity_keys: 1, + re_enable_identity_keys: 0, + add_new_non_unique_keys_to_identity: 0, + add_new_unique_keys_to_identity: 0, + add_new_keys_to_identity: 0, + insert_identity_balance: 0, + initialize_negative_identity_balance: 0, + add_to_identity_balance: 0, + add_to_previous_balance: 0, + apply_balance_change_from_fee_to_identity: 0, + remove_from_identity_balance: 0, + refresh_identity_key_reference_operations: 0, + update_identity_key_limits: Some(0), + credit_storage_refunds_to_owners: Some(0), // new in v15: credits recorded refund owners, reports the unrouted amount for the processing pool + }, + insert: DriveIdentityInsertMethodVersions { + add_new_identity: 0, + }, + contract_info: DriveIdentityContractInfoMethodVersions { + add_potential_contract_info_for_contract_bounded_key: 1, + refresh_potential_contract_info_key_references: 1, + merge_identity_contract_nonce: 0, + }, + cost_estimation: DriveIdentityCostEstimationMethodVersions { + for_authentication_keys_security_level_in_key_reference_tree: 0, + for_balances: 0, + for_token_balances: 0, + for_token_total_supply: 0, + for_contract_info: 0, + for_contract_info_group: 0, + for_contract_info_group_keys: 0, + for_contract_info_group_key_purpose: 0, + for_keys_for_identity_id: 0, + for_negative_credit: 0, + for_purpose_in_key_reference_tree: 0, + for_root_key_reference_tree: 0, + for_update_revision: 0, + for_token_identity_infos: 0, + for_token_perpetual_distribution: 0, + for_token_once_per_identity_distribution: 0, + for_token_pre_programmed_distribution: 0, + for_root_token_ms_interval_distribution: 0, + for_token_selling_prices: 0, + for_token_contract_infos: 0, + }, + withdrawals: DriveIdentityWithdrawalMethodVersions { + document: DriveIdentityWithdrawalDocumentMethodVersions { + fetch_oldest_withdrawal_documents_by_status: 0, + find_withdrawal_documents_by_status_and_transaction_indices: 1, + }, + transaction: DriveIdentityWithdrawalTransactionMethodVersions { + index: DriveIdentityWithdrawalTransactionIndexMethodVersions { + fetch_next_withdrawal_transaction_index: 0, + add_update_next_withdrawal_transaction_index_operation: 0, + }, + queue: DriveIdentityWithdrawalTransactionQueueMethodVersions { + add_enqueue_untied_withdrawal_transaction_operations: 0, + dequeue_untied_withdrawal_transactions: 0, + remove_broadcasted_withdrawal_transactions_after_completion_operations: 0, + move_broadcasted_withdrawal_transactions_back_to_queue_operations: 0, + }, + }, + calculate_current_withdrawal_limit: 1, // changed in v14: daily maximum is a percentage of the total credits a day ago plus the credit inflows of the last 25 hours + record_total_credits_history: Some(0), // new in v14: total credits history for the day-lagged daily withdrawal limit + fetch_total_credits_in_platform_a_day_ago: Some(0), // new in v14 + record_credit_inflows: Some(0), // new in v14: credit inflows sum tree for the net daily withdrawal limit + }, + }; diff --git a/packages/rs-platform-version/src/version/drive_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/mod.rs index 1d7c32caed6..889baec9f92 100644 --- a/packages/rs-platform-version/src/version/drive_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/mod.rs @@ -30,6 +30,7 @@ pub mod drive_token_method_versions; pub mod drive_verify_method_versions; pub mod drive_vote_method_versions; pub mod v1; +pub mod v10; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs new file mode 100644 index 00000000000..267c08ee1f1 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -0,0 +1,172 @@ +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::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::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; +use crate::version::drive_versions::drive_identity_method_versions::v3::DRIVE_IDENTITY_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_state_transition_method_versions::v4::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_structure_version::v1::DRIVE_STRUCTURE_V1; +use crate::version::drive_versions::drive_token_method_versions::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_verify_method_versions::v3::DRIVE_VERIFY_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_vote_method_versions::v2::DRIVE_VOTE_METHOD_VERSIONS_V2; +use crate::version::drive_versions::{ + DriveAssetLockMethodVersions, DriveBalancesMethodVersions, DriveBatchOperationsMethodVersion, + DriveEstimatedCostsMethodVersions, DriveFeesMethodVersions, DriveFetchMethodVersions, + DriveInitializationMethodVersions, DriveMethodVersions, DriveOperationsMethodVersion, + DrivePlatformStateMethodVersions, DrivePlatformSystemMethodVersions, + DrivePrefundedSpecializedMethodVersions, DriveProtocolUpgradeVersions, + DriveProveMethodVersions, DriveSavedBlockTransactionsMethodVersions, + DriveSystemEstimationCostsMethodVersions, DriveVersion, +}; +use grovedb_version::version::v4::GROVE_V4; + +/// Drive version 10. +/// Introduced in protocol v15, carrying the fee-history rules for storage +/// refunds: +/// +/// * **Fee history required for refunds** — `fees.calculate_fee` 0 -> 1. +/// The v1 generation consults the block's fee history on every fee +/// version number and returns an internal error for an owner-attributed +/// (sectioned) storage removal without that history, where v0 priced +/// fee version number 1 against an empty map. Every shipped schedule +/// shares number 1 and the same storage rates, so the credits are +/// unchanged; only a missing history is now an error instead of a +/// silent fallback to the first-generation rates. +/// * **Recorded-owner refund credits** — `DRIVE_IDENTITY_METHOD_VERSIONS_V3` +/// turns on `update.credit_storage_refunds_to_owners`, the primitive +/// block lifecycle paths use to credit each recorded owner of a refund +/// and report the amount whose owner has no balance element. +/// * **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 +/// 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 { + 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_V5, // changed in v10: the six fee-returning document wrappers price before committing an owned transaction + vote: DRIVE_VOTE_METHOD_VERSIONS_V2, + contract: DRIVE_CONTRACT_METHOD_VERSIONS_V5, // changed in v10: 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, + add_estimation_costs_for_levels_up_to_contract_document_type_excluded: 0, + add_estimation_costs_for_contested_document_tree_levels_up_to_contract: 0, + add_estimation_costs_for_contested_document_tree_levels_up_to_contract_document_type_excluded: 0, + }, + asset_lock: DriveAssetLockMethodVersions { + add_asset_lock_outpoint: 0, + add_estimation_costs_for_adding_asset_lock: 0, + fetch_asset_lock_outpoint_info: 0, + }, + verify: DRIVE_VERIFY_METHOD_VERSIONS_V3, // changed in v9: a document batch proof carries the owner's balance (verify state transition v1) + identity: DRIVE_IDENTITY_METHOD_VERSIONS_V3, // changed in v10: credit_storage_refunds_to_owners primitive for lifecycle refund settlement + token: DRIVE_TOKEN_METHOD_VERSIONS_V2, // changed in v9: add_pre_programmed_distributions v1 queues the release-time tree shared by a contract's tokens once + platform_system: DrivePlatformSystemMethodVersions { + estimation_costs: DriveSystemEstimationCostsMethodVersions { + for_total_system_credits_update: 0, + }, + }, + operations: DriveOperationsMethodVersion { + rollback_transaction: 0, + drop_cache: 0, + commit_transaction: 0, + apply_partial_batch_low_level_drive_operations: 0, + apply_partial_batch_grovedb_operations: 0, + apply_batch_low_level_drive_operations: 1, // changed: coalesces bound current-key alias writes per batch + apply_batch_grovedb_operations: 0, + }, + state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4, // changed: document_from_action generation 1 stamps built documents with the contract version (create assigns, replace re-assigns; paired with document serialization format 3) + batch_operations: DriveBatchOperationsMethodVersion { + convert_drive_operations_to_grove_operations: 0, + apply_drive_operations: 2, // changed in v10: the batch is priced before an owned transaction commits + }, + 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_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 { + insert_note: 0, + insert_nullifiers: 0, + update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, + }, + saved_block_transactions: DriveSavedBlockTransactionsMethodVersions { + store_address_balances: 0, + fetch_address_balances: 0, + prove_compacted_address_balance_changes: 1, + compact_address_balances: 0, + cleanup_expired_address_balances: 0, + max_blocks_before_compaction: 64, + max_addresses_before_compaction: 2048, + }, + }, + grove_methods: DRIVE_GROVE_METHOD_VERSIONS_V1, + // changed in v9: GROVE_V4 activates the indexed-tree batch cleanup + // gates (overwrite inspection + delete-tree actual-type cleanup). + // Indexed trees only exist from protocol v14, so activating the + // stricter cleanup with them costs older versions nothing; staying + // on V3 would let a batch overwrite of a ranked index orphan its + // per-axis secondary storage. + grove_version: GROVE_V4, +}; diff --git a/packages/rs-platform-version/src/version/mod.rs b/packages/rs-platform-version/src/version/mod.rs index 1b1635efb42..ae5fd0887e1 100644 --- a/packages/rs-platform-version/src/version/mod.rs +++ b/packages/rs-platform-version/src/version/mod.rs @@ -1,6 +1,6 @@ mod protocol_version; -use crate::version::v14::PROTOCOL_VERSION_14; +use crate::version::v15::PROTOCOL_VERSION_15; pub use protocol_version::*; use std::ops::RangeInclusive; @@ -20,6 +20,7 @@ pub mod v11; pub mod v12; pub mod v13; pub mod v14; +pub mod v15; pub mod v2; pub mod v3; pub mod v4; @@ -33,5 +34,5 @@ pub type ProtocolVersion = u32; pub const ALL_VERSIONS: RangeInclusive = 1..=LATEST_VERSION; -pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_14; +pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_15; pub const INITIAL_PROTOCOL_VERSION: ProtocolVersion = 1; diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index 00cc470bbc7..d6273f0e766 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -22,6 +22,7 @@ use crate::version::v11::PLATFORM_V11; use crate::version::v12::PLATFORM_V12; use crate::version::v13::PLATFORM_V13; use crate::version::v14::PLATFORM_V14; +use crate::version::v15::PLATFORM_V15; use crate::version::v2::PLATFORM_V2; use crate::version::v3::PLATFORM_V3; use crate::version::v4::PLATFORM_V4; @@ -61,6 +62,7 @@ pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ PLATFORM_V12, PLATFORM_V13, PLATFORM_V14, + PLATFORM_V15, ]; #[cfg(feature = "mock-versions")] @@ -69,7 +71,7 @@ pub static PLATFORM_TEST_VERSIONS: OnceLock> = OnceLock::ne #[cfg(feature = "mock-versions")] const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = &[TEST_PLATFORM_V2, TEST_PLATFORM_V3]; -pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V14; +pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V15; pub const DESIRED_PLATFORM_VERSION: &PlatformVersion = LATEST_PLATFORM_VERSION; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs new file mode 100644 index 00000000000..c37dbaefa3b --- /dev/null +++ b/packages/rs-platform-version/src/version/v15.rs @@ -0,0 +1,92 @@ +use crate::version::consensus_versions::ConsensusVersions; +use crate::version::dpp_versions::dpp_asset_lock_versions::v1::DPP_ASSET_LOCK_VERSIONS_V1; +use crate::version::dpp_versions::dpp_contract_versions::v6::CONTRACT_VERSIONS_V6; +use crate::version::dpp_versions::dpp_costs_versions::v1::DPP_COSTS_VERSIONS_V1; +use crate::version::dpp_versions::dpp_document_versions::v4::DOCUMENT_VERSIONS_V4; +use crate::version::dpp_versions::dpp_factory_versions::v1::DPP_FACTORY_VERSIONS_V1; +use crate::version::dpp_versions::dpp_identity_versions::v1::IDENTITY_VERSIONS_V1; +use crate::version::dpp_versions::dpp_method_versions::v3::DPP_METHOD_VERSIONS_V3; +use crate::version::dpp_versions::dpp_state_transition_conversion_versions::v2::STATE_TRANSITION_CONVERSION_VERSIONS_V2; +use crate::version::dpp_versions::dpp_state_transition_method_versions::v2::STATE_TRANSITION_METHOD_VERSIONS_V2; +use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v3::STATE_TRANSITION_SERIALIZATION_VERSIONS_V3; +use crate::version::dpp_versions::dpp_state_transition_versions::v4::STATE_TRANSITION_VERSIONS_V4; +use crate::version::dpp_versions::dpp_token_versions::v3::TOKEN_VERSIONS_V3; +use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VERSIONS_V5; +use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; +use crate::version::dpp_versions::DPPVersion; +use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; +use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3; +use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIVE_ABCI_STRUCTURE_VERSIONS_V2; +use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v3::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3; +use crate::version::drive_abci_versions::DriveAbciVersion; +use crate::version::drive_versions::v10::DRIVE_VERSION_V10; +use crate::version::fee::v3::FEE_VERSION3; +use crate::version::protocol_version::PlatformVersion; +use crate::version::system_data_contract_versions::v3::SYSTEM_DATA_CONTRACT_VERSIONS_V3; +use crate::version::system_limits::v4::SYSTEM_LIMITS_V4; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; + +/// v15 hosts the storage refund fee-history rules: +/// +/// 1. **Fee history required for storage refunds**: `DRIVE_VERSION_V10` +/// bumps `fees.calculate_fee` to 1. A storage refund for owner-attributed +/// bytes is priced only with the fee history of the block that removes +/// the bytes; the history is consulted on every fee version number and a +/// missing history is an internal error instead of a silent fallback to +/// the first-generation storage rates. Every shipped schedule shares fee +/// version number 1 and the same storage rates, so refund credits are +/// unchanged for every shipped input; the boundary makes the absence of +/// history an error from this version onward. +/// 2. **Recorded-owner refund credits**: the same drive table turns on +/// `identity.update.credit_storage_refunds_to_owners`, the primitive that +/// credits each recorded owner of a refund without consulting any key or +/// permission and reports the amount whose owner has no balance element, +/// so block lifecycle paths can settle it into the current epoch's +/// processing pool. +/// 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, +/// `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 { + protocol_version: PROTOCOL_VERSION_15, + drive: DRIVE_VERSION_V10, // changed: calculate_fee v1 (fee history required for refunds) + identity table v3 (credit_storage_refunds_to_owners) + drive_abci: DriveAbciVersion { + structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, + methods: DRIVE_ABCI_METHOD_VERSIONS_V10, + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, + withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, + query: DRIVE_ABCI_QUERY_VERSIONS_V3, + checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, + }, + dpp: DPPVersion { + costs: DPP_COSTS_VERSIONS_V1, + validation: DPP_VALIDATION_VERSIONS_V5, + state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, + state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, + state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, + state_transitions: STATE_TRANSITION_VERSIONS_V4, + contract_versions: CONTRACT_VERSIONS_V6, + document_versions: DOCUMENT_VERSIONS_V4, + identity_versions: IDENTITY_VERSIONS_V1, + voting_versions: VOTING_VERSION_V2, + token_versions: TOKEN_VERSIONS_V3, + asset_lock_versions: DPP_ASSET_LOCK_VERSIONS_V1, + methods: DPP_METHOD_VERSIONS_V3, + factory_versions: DPP_FACTORY_VERSIONS_V1, + }, + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, + fee_version: FEE_VERSION3, + system_limits: SYSTEM_LIMITS_V4, + consensus: ConsensusVersions { + tenderdash_consensus_version: 1, + }, +};