From 2a7d86930a9ad61cb52dd93a5758bb2833d81e5e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 01:31:55 +0300 Subject: [PATCH 01/21] feat(platform)!: destroy a frozen identity's escrowed listings with its funds (PV15) A frozen identity's open listings hold its tokens in escrow, out of the balance a TokenDestroyFrozenFunds burns. From protocol version 15 the destroy also reads the frozen identity's open listings of the token (order_escrow.fetch_frozen_listings, byOwner index, oldest first, at most max_frozen_listings_destroyed_per_transition = 16), destroys what they hold with the balance through TokenDestroyFrozenHolding (one supply decrement), deletes their ask documents and records the whole amount. Converter generation 1 (DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5) does this; v0's operations are unchanged. Offers hold credits and are left alone. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/token-marketplace.md | 16 ++ .../batch/tests/token/marketplace/mod.rs | 223 +++++++++++++++++- .../drive/tokens/order_escrow/fetch_order.rs | 114 +++++++++ .../token_destroy_frozen_funds_transition.rs | 192 ++++++++++----- .../mod.rs | 17 ++ .../v0/mod.rs | 32 +++ .../v0/transformer.rs | 42 +++- .../token_transition_action_type.rs | 4 + .../state_transition_action/batch/tests.rs | 2 + .../src/util/batch/drive_op_batch/mod.rs | 18 ++ .../src/util/batch/drive_op_batch/token.rs | 55 +++++ .../mod.rs | 1 + .../v5.rs | 94 ++++++++ .../drive_token_method_versions/mod.rs | 6 +- .../drive_token_method_versions/v1.rs | 1 + .../drive_token_method_versions/v3.rs | 11 +- .../src/version/drive_versions/v10.rs | 7 +- .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 4 + .../src/version/system_limits/v1.rs | 1 + .../src/version/system_limits/v2.rs | 1 + .../src/version/system_limits/v3.rs | 1 + .../src/version/system_limits/v4.rs | 5 + .../rs-platform-version/src/version/v15.rs | 8 + packages/token-history-contract/src/lib.rs | 4 +- 25 files changed, 788 insertions(+), 72 deletions(-) create mode 100644 packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 33c6a38488d..f89431e8e90 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -195,6 +195,22 @@ The buyer's credits are checked with the batch's fees, which count the escrow li buyer pays the storage of the `bid` and gets it back when the offer closes. A `TokenPlaceBid` is proven by its `bid` document, and a `TokenFillBid` by the seller's token balance. +## Frozen accounts + +A frozen account's open listings cannot be filled, and the tokens they hold sit in escrow, out of +the frozen balance. From protocol version 15 a `TokenDestroyFrozenFunds` destroys them with the +balance: it reads the frozen identity's open listings of the token, oldest first, at most +`max_frozen_listings_destroyed_per_transition` (16), destroys what they hold in escrow, deletes +their `ask` documents and records the whole amount destroyed in its `destroyFrozenFunds` history +document. A frozen identity with more listings needs one more destroy per 16. Its offers hold +credits, which are not the token's to destroy, and stay; it can still withdraw them. + +```text +Frozen identity: balance 5,000, listings of 3,000 and 2,000 +Before (protocol version 14 rules): the destroy burns 5,000; the 5,000 in escrow stays +From protocol version 15: the destroy burns all 10,000 and deletes both listings +``` + ## History A token that keeps transfer history (`keepsHistory.keepsTransferHistory`, the default) also diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index f8e26ef0bc2..cc31529c6c4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -964,7 +964,7 @@ mod offers { .expect("expected to fetch the escrow") } - fn offer(&self, order_id: Identifier) -> Option { + pub(super) fn offer(&self, order_id: Identifier) -> Option { let platform_version = PlatformVersion::latest(); let token_market_contract = self .platform @@ -1376,7 +1376,7 @@ mod history { impl Market { /// Every document of `document_type_name` in the token history contract. - fn history(&self, document_type_name: &str) -> Vec { + pub(super) fn history(&self, document_type_name: &str) -> Vec { let platform_version = PlatformVersion::latest(); let token_history_contract = self .platform @@ -1526,3 +1526,222 @@ mod history { assert!(market.history("orderFilled").is_empty()); } } + +/// A frozen identity's open listings hold its tokens in escrow, out of its balance's reach: from +/// protocol version 15 a `TokenDestroyFrozenFunds` destroys what they hold with the balance, +/// at most `max_frozen_listings_destroyed_per_transition` of them, oldest first. +mod frozen { + use super::*; + use dpp::document::DocumentV0Getters; + + /// The contract owner (the seller) may freeze an account and destroy its frozen funds. + fn make_token_tradeable_and_freezable(token_configuration: &mut TokenConfiguration) { + make_token_tradeable(token_configuration); + let owner_rules = ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }); + token_configuration.set_freeze_rules(owner_rules.clone()); + token_configuration.set_destroy_frozen_funds_rules(owner_rules); + } + + impl Market { + /// The seller transfers `amount` to the buyer. + async fn give_buyer(&mut self, amount: u64) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let transfer = BatchTransition::new_token_transfer_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + amount, + self.buyer.id(), + None, + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the transfer"); + self.assert_executes(&transfer, &BlockInfo::default()); + } + + /// A listing by the buyer at block time `time_ms`, and its order id. + async fn buyer_lists(&mut self, amount: u64, time_ms: u64) -> Identifier { + let platform_version = PlatformVersion::latest(); + let nonce = self.buyer.take_nonce(); + let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.buyer.id(), nonce); + let place = BatchTransition::new_token_place_ask_transition( + self.token_id, + self.buyer.id(), + self.contract.id(), + 0, + amount, + PRICE, + 1, + None, + &self.buyer.key, + nonce, + 0, + &self.buyer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the listing"); + self.assert_executes(&place, &at_time(time_ms)); + order_id + } + + /// The seller, the contract owner, freezes the buyer. + async fn freeze_buyer(&mut self) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let freeze = BatchTransition::new_token_freeze_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + self.buyer.id(), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the freeze"); + self.assert_executes(&freeze, &BlockInfo::default()); + } + + /// The seller destroys the buyer's frozen funds. + async fn destroy_buyers_frozen_funds(&mut self) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let destroy = BatchTransition::new_token_destroy_frozen_funds_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + self.buyer.id(), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the destroy"); + self.assert_executes(&destroy, &at_time(1_000_000)); + } + + fn supply(&self) -> u64 { + self.platform + .drive + .fetch_token_total_supply( + self.token_id.to_buffer(), + None, + PlatformVersion::latest(), + ) + .expect("expected to fetch the supply") + .expect("expected the token to have a supply") + } + } + + #[tokio::test] + async fn should_destroy_a_frozen_identitys_listings_with_its_balance() { + let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); + market.give_buyer(10_000).await; + let first = market.buyer_lists(3_000, 1).await; + let second = market.buyer_lists(2_000, 2).await; + assert_eq!(market.token_balance(market.buyer.id()), Some(5_000)); + market.freeze_buyer().await; + + market.destroy_buyers_frozen_funds().await; + + assert_eq!(market.token_balance(market.buyer.id()), Some(0)); + for order_id in [first, second] { + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None, "the listing is deleted"); + } + assert_eq!(market.supply(), BASE_SUPPLY - 10_000); + market.assert_supply_balanced(); + + let destroyed = market.history("destroyFrozenFunds"); + assert_eq!(destroyed.len(), 1); + assert_eq!( + destroyed[0] + .properties() + .get("destroyedAmount") + .and_then(|amount| amount.to_integer::().ok()), + Some(10_000), + "the record counts the balance and the escrow" + ); + } + + #[tokio::test] + async fn should_destroy_the_oldest_listings_up_to_the_limit_per_transition() { + let limit = PlatformVersion::latest() + .system_limits + .max_frozen_listings_destroyed_per_transition as u64; + let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); + market.give_buyer(10 * (limit + 1)).await; + let mut listings = vec![]; + for time_ms in 1..=limit + 1 { + listings.push(market.buyer_lists(10, time_ms).await); + } + market.freeze_buyer().await; + + market.destroy_buyers_frozen_funds().await; + let newest = listings.pop().expect("expected the newest listing"); + for order_id in &listings { + assert_eq!(market.escrow(*order_id), None, "the oldest are destroyed"); + } + assert_eq!( + market.escrow(newest), + Some(10), + "the newest waits for the next" + ); + market.assert_supply_balanced(); + + market.destroy_buyers_frozen_funds().await; + assert_eq!(market.escrow(newest), None); + assert_eq!(market.supply(), BASE_SUPPLY - 10 * (limit + 1)); + market.assert_supply_balanced(); + } + + /// An offer holds credits, which are not the token issuer's to destroy: it stays, and the + /// frozen identity can still withdraw it. + #[tokio::test] + async fn should_leave_a_frozen_identitys_offers() { + let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); + market.give_buyer(100).await; + let (place, order_id) = market.place_bid(1_000, PRICE, 1, None).await; + market.assert_executes(&place, &BlockInfo::default()); + market.freeze_buyer().await; + + market.destroy_buyers_frozen_funds().await; + + assert_eq!(market.token_balance(market.buyer.id()), Some(0)); + assert_eq!( + market.offer(order_id).map(|offer| offer.escrow), + Some(10_000), + "the offer still holds its escrow" + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs index ab4a967138a..fd2e8c3ef8b 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs @@ -1,7 +1,9 @@ use crate::drive::contract::DataContractFetchInfo; use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; +use crate::query::ordering::OrderClause; use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; use dpp::block::epoch::Epoch; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -9,10 +11,13 @@ use dpp::data_contract::DataContract; use dpp::document::Document; use dpp::identifier::Identifier; use dpp::platform_value::Value; +use dpp::tokens::token_order::TokenOrder; use dpp::tokens::token_order_side::TokenOrderSide; use dpp::version::PlatformVersion; use grovedb::TransactionArg; use grovedb_costs::OperationCost; +use indexmap::IndexMap; +use std::collections::BTreeMap; use std::sync::Arc; impl Drive { @@ -82,4 +87,113 @@ impl Drive { let cost = outcome.cost(); Ok((outcome.documents_owned().into_iter().next(), cost)) } + + /// The open listings of `owner_id` for `token_id` that a `TokenDestroyFrozenFunds` destroys + /// with the frozen balance: the oldest first, at most + /// `max_frozen_listings_destroyed_per_transition`, read through the `ask` type's `byOwner` + /// index, with the processing cost of the read billed at `epoch`. None where + /// `fetch_frozen_listings` is `None`, which reads nothing: there the frozen balance alone is + /// destroyed, as before the marketplace. + pub fn fetch_frozen_token_listings( + &self, + token_id: Identifier, + owner_id: Identifier, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Vec, u64), Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .fetch_frozen_listings + { + None => Ok((vec![], 0)), + Some(0) => self.fetch_frozen_token_listings_v0( + token_id, + owner_id, + epoch, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_frozen_token_listings".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + fn fetch_frozen_token_listings_v0( + &self, + token_id: Identifier, + owner_id: Identifier, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Vec, u64), Error> { + let token_market_contract = self + .cache + .system_data_contracts + .load_token_market(platform_version)?; + let document_type = token_market_contract + .document_type_for_name(TokenOrderSide::Ask.document_type_name())?; + let equal = |field: &str, value: Identifier| { + ( + field.to_string(), + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(value.to_buffer()), + }, + ) + }; + let query = DriveDocumentQuery { + contract: &token_market_contract, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses: BTreeMap::from([ + equal("$ownerId", owner_id), + equal("tokenId", token_id), + ]), + }, + offset: None, + limit: Some( + platform_version + .system_limits + .max_frozen_listings_destroyed_per_transition, + ), + order_by: IndexMap::from([( + "$createdAt".to_string(), + OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }, + )]), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + let outcome = self.query_documents( + query, + Some(epoch), + false, + transaction, + Some(platform_version.protocol_version), + )?; + let cost = outcome.cost(); + let listings = outcome + .documents_owned() + .iter() + .map(|document| TokenOrder::from_document(document, TokenOrderSide::Ask)) + .collect::, _>>()?; + Ok((listings, cost)) + } } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs index 494d4061c21..ca480e570d9 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs @@ -5,15 +5,18 @@ use dpp::group::group_action::v0::GroupActionV0; use dpp::group::GroupStateTransitionResolvedInfo; use dpp::identifier::Identifier; use dpp::tokens::token_event::TokenEvent; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; use platform_version::version::PlatformVersion; use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; -use crate::util::batch::{DriveOperation, IdentityOperationType}; +use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; use crate::util::batch::drive_op_batch::{GroupOperationType, TokenOperationType}; -use crate::util::batch::DriveOperation::{GroupOperation, IdentityOperation, TokenOperation}; +use crate::util::batch::DriveOperation::{DocumentOperation, GroupOperation, IdentityOperation, TokenOperation}; impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransitionAction { fn into_high_level_batch_drive_operations<'b>( @@ -29,79 +32,142 @@ impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransition .convert_to_high_level_operations .token_destroy_frozen_funds_transition { - 0 => { - let data_contract_id = self.base().data_contract_id(); + 0 => self.destroy_frozen_funds_operations(owner_id, false), + // From protocol version 15: also destroys what the frozen identity's open listings + // hold in escrow, and deletes their `ask` documents. + 1 => self.destroy_frozen_funds_operations(owner_id, true), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenDestroyFrozenFundsTransitionAction::into_high_level_document_drive_operations" + .to_string(), + known_versions: vec![0, 1], + received: version, + })), + } + } +} - let identity_contract_nonce = self.base().identity_contract_nonce(); +impl TokenDestroyFrozenFundsTransitionAction { + /// The operations of a destroy of frozen funds. Without `destroy_listings` (generation 0) + /// the frozen balance alone is burned, which is all there is before the marketplace. With it + /// (generation 1) the frozen identity's open listings the transformer read are destroyed + /// too: what they hold in escrow leaves the supply with the balance, their `ask` documents + /// are deleted, and the recorded amount is everything destroyed. + fn destroy_frozen_funds_operations<'b>( + self, + owner_id: Identifier, + destroy_listings: bool, + ) -> Result>, Error> { + let data_contract_id = self.base().data_contract_id(); - let mut ops = vec![IdentityOperation( - IdentityOperationType::UpdateIdentityContractNonce { - identity_id: owner_id.into_buffer(), - contract_id: data_contract_id.into_buffer(), - nonce: identity_contract_nonce, - }, - )]; + let identity_contract_nonce = self.base().identity_contract_nonce(); - if let Some(GroupStateTransitionResolvedInfo { - group_contract_position, - action_id, - action_is_proposer, - signer_power, - .. - }) = self.base().store_in_group() - { - let event = - TokenEvent::DestroyFrozenFunds(self.frozen_identity_id(), self.amount(), self.public_note().cloned()); + let listings: Vec = if destroy_listings { + self.escrowed_listings().to_vec() + } else { + vec![] + }; + let destroyed_amount = if listings.is_empty() { + self.amount() + } else { + self.total_destroyed() + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a frozen holding is more tokens than a u64 holds", + )))? + }; - let initialize_with_insert_action_info = if *action_is_proposer { - Some(GroupAction::V0(GroupActionV0 { - contract_id: self.base().data_contract_id(), - proposer_id: owner_id, - token_contract_position: self.base().token_position(), - event: GroupActionEvent::TokenEvent(event), - })) - } else { - None - }; + let mut ops = vec![IdentityOperation( + IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }, + )]; - ops.push(GroupOperation(GroupOperationType::AddGroupAction { - contract_id: data_contract_id, - group_contract_position: *group_contract_position, - initialize_with_insert_action_info, - action_id: *action_id, - signer_identity_id: owner_id, - signer_power: *signer_power, - closes_group_action: self.base().perform_action(), - })); - } + if let Some(GroupStateTransitionResolvedInfo { + group_contract_position, + action_id, + action_is_proposer, + signer_power, + .. + }) = self.base().store_in_group() + { + let event = TokenEvent::DestroyFrozenFunds( + self.frozen_identity_id(), + destroyed_amount, + self.public_note().cloned(), + ); - if self.base().perform_action() { - ops.push(TokenOperation(TokenOperationType::TokenBurn { - token_id: self.token_id(), - identity_balance_holder_id: self.frozen_identity_id(), - burn_amount: self.amount(), - })); + let initialize_with_insert_action_info = if *action_is_proposer { + Some(GroupAction::V0(GroupActionV0 { + contract_id: self.base().data_contract_id(), + proposer_id: owner_id, + token_contract_position: self.base().token_position(), + event: GroupActionEvent::TokenEvent(event), + })) + } else { + None + }; + + ops.push(GroupOperation(GroupOperationType::AddGroupAction { + contract_id: data_contract_id, + group_contract_position: *group_contract_position, + initialize_with_insert_action_info, + action_id: *action_id, + signer_identity_id: owner_id, + signer_power: *signer_power, + closes_group_action: self.base().perform_action(), + })); + } - ops.push(TokenOperation(TokenOperationType::TokenHistory { + if self.base().perform_action() { + if listings.is_empty() { + ops.push(TokenOperation(TokenOperationType::TokenBurn { + token_id: self.token_id(), + identity_balance_holder_id: self.frozen_identity_id(), + burn_amount: self.amount(), + })); + } else { + ops.push(TokenOperation( + TokenOperationType::TokenDestroyFrozenHolding { token_id: self.token_id(), - owner_id, - nonce: identity_contract_nonce, - event: TokenEvent::DestroyFrozenFunds( - self.frozen_identity_id(), - self.amount(), - self.public_note_owned(), + holder_id: self.frozen_identity_id(), + balance_amount: self.amount(), + listings: listings + .iter() + .map(|listing| (listing.id, listing.remaining)) + .collect(), + }, + )); + let token_market_contract = self.token_market_contract().cloned().ok_or( + Error::Drive(DriveError::CorruptedCodeExecution( + "listings to destroy come with the token market contract", + )), + )?; + for listing in &listings { + ops.push(DocumentOperation(DocumentOperationType::DeleteDocument { + document_id: listing.id, + contract_info: DataContractInfo::DataContractFetchInfo( + token_market_contract.clone(), + ), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( + TokenOrderSide::Ask.document_type_name(), ), })); } - - Ok(ops) } - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "TokenDestroyFrozenFundsTransitionAction::into_high_level_document_drive_operations" - .to_string(), - known_versions: vec![0], - received: version, - })), + + ops.push(TokenOperation(TokenOperationType::TokenHistory { + token_id: self.token_id(), + owner_id, + nonce: identity_contract_nonce, + event: TokenEvent::DestroyFrozenFunds( + self.frozen_identity_id(), + destroyed_amount, + self.public_note_owned(), + ), + })); } + + Ok(ops) } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs index e6ff7805326..e46f5f69dbc 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs @@ -1,6 +1,9 @@ +use crate::drive::contract::DataContractFetchInfo; use derive_more::From; use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; /// transformer module for token destroy_frozen_funds transition action pub mod transformer; @@ -56,6 +59,18 @@ impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 } } + fn escrowed_listings(&self) -> &[TokenOrder] { + match self { + TokenDestroyFrozenFundsTransitionAction::V0(v0) => &v0.escrowed_listings, + } + } + + fn token_market_contract(&self) -> Option<&Arc> { + match self { + TokenDestroyFrozenFundsTransitionAction::V0(v0) => v0.token_market_contract.as_ref(), + } + } + fn public_note(&self) -> Option<&String> { match self { TokenDestroyFrozenFundsTransitionAction::V0(v0) => v0.public_note.as_ref(), @@ -125,6 +140,8 @@ mod tests { frozen_identity_id: Identifier::from(TEST_FROZEN_ID), amount: TEST_AMOUNT, public_note: Some("destroyed".to_string()), + escrowed_listings: vec![], + token_market_contract: None, } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/mod.rs index 33c0fc151b9..74beed2f45c 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/mod.rs @@ -3,6 +3,7 @@ mod transformer; use std::sync::Arc; use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; +use dpp::tokens::token_order::TokenOrder; use crate::drive::contract::DataContractFetchInfo; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; @@ -15,6 +16,12 @@ pub struct TokenDestroyFrozenFundsTransitionActionV0 { pub frozen_identity_id: Identifier, /// The amount that will be burned pub amount: TokenAmount, + /// The frozen identity's open listings destroyed with its balance, oldest first (protocol + /// version 15; empty before) + pub escrowed_listings: Vec, + /// The token market contract, whose `ask` documents record the listings; read only when + /// there are listings to destroy + pub token_market_contract: Option>, /// A public note pub public_note: Option, } @@ -39,6 +46,21 @@ pub trait TokenDestroyFrozenFundsTransitionActionAccessorsV0 { /// Sets the amount of tokens that the identity had and will be burned fn set_amount(&mut self, amount: TokenAmount); + /// The frozen identity's open listings destroyed with its balance + fn escrowed_listings(&self) -> &[TokenOrder]; + + /// The token market contract, when there are listings to destroy + fn token_market_contract(&self) -> Option<&Arc>; + + /// Everything destroyed: the balance and what the listings hold in escrow + fn total_destroyed(&self) -> Option { + self.escrowed_listings() + .iter() + .try_fold(self.amount(), |total, listing| { + total.checked_add(listing.remaining) + }) + } + /// Returns the token position in the contract fn token_position(&self) -> u16 { self.base().token_position() @@ -101,6 +123,14 @@ impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 self.amount = amount; } + fn escrowed_listings(&self) -> &[TokenOrder] { + &self.escrowed_listings + } + + fn token_market_contract(&self) -> Option<&Arc> { + self.token_market_contract.as_ref() + } + fn public_note(&self) -> Option<&String> { self.public_note.as_ref() } @@ -147,6 +177,8 @@ mod tests { frozen_identity_id: frozen, amount, public_note: note.map(|s| s.to_string()), + escrowed_listings: vec![], + token_market_contract: None, } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs index f82e4865240..5413d5e012c 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs @@ -90,7 +90,7 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { platform_version, )?; - let fee_result = Drive::calculate_fee( + let mut fee_result = Drive::calculate_fee( None, Some(drive_operations), &block_info.epoch, @@ -148,12 +148,31 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } }; + // From protocol version 15 the frozen identity's open listings are destroyed with its + // balance: what they hold sits in escrow, out of the balance's reach. Earlier versions + // read none. + let (escrowed_listings, listings_read_cost) = drive.fetch_frozen_token_listings( + base.token_id(), + frozen_identity_id, + &block_info.epoch, + transaction, + platform_version, + )?; + fee_result.processing_fee = fee_result.processing_fee.saturating_add(listings_read_cost); + let token_market_contract = if escrowed_listings.is_empty() { + None + } else { + Some(drive.token_market_contract_fetch_info(platform_version)?) + }; + Ok(( BatchedTransitionAction::TokenAction(TokenTransitionAction::DestroyFrozenFundsAction( TokenDestroyFrozenFundsTransitionActionV0 { base: base_action, frozen_identity_id, amount: token_amount, + escrowed_listings, + token_market_contract, public_note: change_note.unwrap_or(public_note), } .into(), @@ -237,7 +256,7 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { platform_version, )?; - let fee_result = Drive::calculate_fee( + let mut fee_result = Drive::calculate_fee( None, Some(drive_operations), &block_info.epoch, @@ -296,12 +315,31 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } }; + // From protocol version 15 the frozen identity's open listings are destroyed with its + // balance: what they hold sits in escrow, out of the balance's reach. Earlier versions + // read none. + let (escrowed_listings, listings_read_cost) = drive.fetch_frozen_token_listings( + base.token_id(), + *frozen_identity_id, + &block_info.epoch, + transaction, + platform_version, + )?; + fee_result.processing_fee = fee_result.processing_fee.saturating_add(listings_read_cost); + let token_market_contract = if escrowed_listings.is_empty() { + None + } else { + Some(drive.token_market_contract_fetch_info(platform_version)?) + }; + Ok(( BatchedTransitionAction::TokenAction(TokenTransitionAction::DestroyFrozenFundsAction( TokenDestroyFrozenFundsTransitionActionV0 { base: base_action, frozen_identity_id: *frozen_identity_id, amount: token_amount, + escrowed_listings, + token_market_contract, public_note: change_note.unwrap_or(public_note.clone()), } .into(), diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs index 2773e434f42..1b6fe6bd476 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs @@ -211,6 +211,8 @@ mod tests { frozen_identity_id: Identifier::new([8u8; 32]), amount: 9, public_note: None, + escrowed_listings: vec![], + token_market_contract: None, }, ), ); @@ -337,6 +339,8 @@ mod tests { frozen_identity_id: Identifier::new([8u8; 32]), amount: 1, public_note: None, + escrowed_listings: vec![], + token_market_contract: None, }, ), ), diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index 144bbad17a8..93640d47683 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -1821,6 +1821,8 @@ fn make_destroy_v0() -> TokenDestroyFrozenFundsTransitionActionV0 { frozen_identity_id: Identifier::from([0xCC; 32]), amount: 400, public_note: Some("destroy note".to_string()), + escrowed_listings: vec![], + token_market_contract: None, } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index ee47466e199..f1559c0d8c0 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -660,6 +660,24 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { TokenBalanceKey::Holder(*token_id, *recipient_id), TokenBalanceKey::OrderEscrow(*token_id, *order_id), ], + // Destroying a frozen holding writes its balance, the escrow of each of its listings + // and the supply, once each. + TokenOperationType::TokenDestroyFrozenHolding { + token_id, + holder_id, + listings, + .. + } => [ + TokenBalanceKey::Holder(*token_id, *holder_id), + TokenBalanceKey::Supply(*token_id), + ] + .into_iter() + .chain( + listings + .iter() + .map(|(order_id, _)| TokenBalanceKey::OrderEscrow(*token_id, *order_id)), + ) + .collect(), // An offer's escrow holds credits, which its placement, fills and cancel move through // identity balance operations of their own. TokenOperationType::TokenHoldCreditsInOrderEscrow { order_id, .. } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index d164c9a2844..60cfe60fc43 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -5,6 +5,7 @@ use crate::state_transition_action::shielded::ShieldedActionNote; use crate::util::batch::drive_op_batch::DriveLowLevelOperationConverter; use dpp::balances::credits::TokenAmount; use dpp::fee::Credits; +use crate::error::drive::DriveError; use dpp::block::block_info::BlockInfo; use dpp::identifier::Identifier; use dpp::prelude::{IdentityNonce, TimestampMillis}; @@ -221,6 +222,20 @@ pub enum TokenOperationType { /// What the escrow holds after the release remaining_after: TokenAmount, }, + /// Destroys a frozen identity's holding of a token: `balance_amount` from its balance and what + /// each of its `listings` (order id, amount) holds in escrow, and lowers the total supply by + /// all of it once (the token marketplace, protocol version 15). The listings' `ask` + /// documents are deleted by document operations of the same batch. + TokenDestroyFrozenHolding { + /// The token id + token_id: Identifier, + /// The frozen identity + holder_id: Identifier, + /// What its balance holds + balance_amount: TokenAmount, + /// Its open listings and what each holds in escrow + listings: Vec<(Identifier, TokenAmount)>, + }, /// Holds `credits` in escrow for the new offer `order_id` (the token marketplace, protocol /// version 15). The credits come out of the buyer's balance through an identity balance /// operation of the same batch; the escrow sits under the prefunded specialized balances, @@ -254,6 +269,46 @@ impl DriveLowLevelOperationConverter for TokenOperationType { platform_version: &PlatformVersion, ) -> Result, Error> { match self { + TokenOperationType::TokenDestroyFrozenHolding { + token_id, + holder_id, + balance_amount, + listings, + } => { + let token_id = token_id.to_buffer(); + let total = listings + .iter() + .try_fold(balance_amount, |total, (_, amount)| { + total.checked_add(*amount) + }) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a frozen holding is more tokens than a u64 holds", + )))?; + let mut batch_operations = drive.remove_from_identity_token_balance_operations( + token_id, + holder_id.to_buffer(), + balance_amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + for (order_id, _) in listings { + batch_operations.extend(drive.remove_token_order_escrow_operations( + token_id, + order_id.to_buffer(), + estimated_costs_only_with_layer_info, + platform_version, + )?); + } + batch_operations.extend(drive.remove_from_token_total_supply_operations( + token_id, + total, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + Ok(batch_operations) + } TokenOperationType::TokenHoldCreditsInOrderEscrow { order_id, credits } => drive .add_to_credit_order_escrow_operations( order_id.to_buffer(), diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs index 069d9f9d217..e2742a88ec6 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs @@ -2,6 +2,7 @@ pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; use crate::version::drive_versions::DriveDataContractOperationMethodVersions; use versioned_feature_core::FeatureVersion; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs new file mode 100644 index 00000000000..9096d828ea6 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs @@ -0,0 +1,94 @@ +use crate::version::drive_versions::drive_state_transition_method_versions::{ + DriveDocumentFromActionVersions, + DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions, + DriveStateTransitionMethodVersions, DriveStateTransitionOperationMethodVersions, +}; +use crate::version::drive_versions::DriveDataContractOperationMethodVersions; + +// Protocol 15: v4 with `token_destroy_frozen_funds_transition` 1, which destroys what a frozen +// identity's open listings hold with its balance. +pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5: DriveStateTransitionMethodVersions = + DriveStateTransitionMethodVersions { + operations: DriveStateTransitionOperationMethodVersions { + finalization_tasks: 0, + contracts: DriveDataContractOperationMethodVersions { + finalization_tasks: 0, + }, + }, + convert_to_high_level_operations: + DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions { + data_contract_create_transition: 1, // changed: also emits contract group registration and membership operations for version 1 create transitions + data_contract_update_transition: 0, + document_create_transition: 0, + document_delete_transition: 0, + document_index_only_delete_transition: 0, + // PROTOCOL_VERSION_13: v1 rewrites a transferred or purchased + // DPNS domain document's `records.identity` to the new owner + // so the username resolves to the buyer. v0 stays for + // PROTOCOL_VERSION_12 chain replay. + document_purchase_transition: 1, // changed + document_replace_transition: 0, + document_transfer_transition: 1, // changed + document_update_price_transition: 1, // changed + token_burn_transition: 0, + token_mint_transition: 0, + token_transfer_transition: 0, + documents_batch_transition: 1, // changed: also deletes the lapsed contract suspensions the batch action carries + identity_create_transition: 0, + identity_create_from_addresses_transition: 0, + identity_credit_transfer_transition: 0, + identity_credit_withdrawal_transition: 0, + identity_top_up_transition: 0, + identity_top_up_from_addresses_transition: 0, + identity_update_transition: 1, + identity_key_limits_update_transition: 0, + contract_user_moderation_transition: 0, + contract_fee_claim_transition: 0, + masternode_vote_transition: 0, + bump_identity_data_contract_nonce: 0, + bump_identity_nonce: 0, + partially_use_asset_lock: 0, + token_freeze_transition: 0, + token_unfreeze_transition: 0, + token_emergency_action_transition: 0, + token_destroy_frozen_funds_transition: 1, // changed: also destroys the frozen identity's escrowed listings + token_config_update_transition: 0, + token_claim_transition: 0, + token_direct_purchase_transition: 0, + token_set_price_for_direct_purchase_transition: 0, + token_shield_transition: 0, + token_unshield_transition: 0, + token_shielded_transfer_transition: 0, + token_mint_to_pool_transition: 0, + token_burn_from_pool_transition: 0, + token_claim_to_pool_transition: 0, + token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, + token_place_bid_transition: 0, + token_fill_bid_transition: 0, + identity_credit_transfer_to_addresses_transition: 0, + address_funds_transfer_transition: 0, + address_credit_withdrawal_transition: 0, + address_funding_from_asset_lock_transition: 0, + // PROTOCOL_VERSION_14: v1 also records the nullifiers the + // shield's actions reveal, as the spends do. v0 stays for + // PROTOCOL_VERSION_12 and 13 chain replay. + shield_transition: 1, // changed + shield_from_asset_lock_transition: 1, // changed + shielded_transfer_transition: 0, + unshield_transition: 0, + shielded_withdrawal_transition: 0, + identity_create_from_shielded_pool_transition: 0, + shield_from_identity_transition: 0, + identity_top_up_from_shielded_pool_transition: 0, + token_shielded_transfer_with_shielded_fee_transition: 0, + token_unshield_with_shielded_fee_transition: 0, + token_purchase_from_shielded_pool_transition: 0, + }, + document_from_action: DriveDocumentFromActionVersions { + document_from_create_transition_action: 1, // changed + document_from_replace_transition_action: 1, // changed + }, + }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs index 0f8b135b4d2..523c04738a2 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs @@ -1,4 +1,4 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; @@ -28,6 +28,10 @@ pub struct DriveTokenOrderEscrowMethodVersions { pub fetch_credits: FeatureVersion, pub estimated_costs_for_tokens: FeatureVersion, pub estimated_costs_for_credits: FeatureVersion, + /// Reads the open listings of a frozen identity that `TokenDestroyFrozenFunds` destroys with + /// its balance. `None` reads none: the frozen balance alone is destroyed, as before the + /// marketplace. + pub fetch_frozen_listings: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs index 1318dfa25e4..a8b9b0598d8 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs @@ -83,5 +83,6 @@ pub const DRIVE_TOKEN_METHOD_VERSIONS_V1: DriveTokenMethodVersions = DriveTokenM fetch_credits: 0, estimated_costs_for_tokens: 0, estimated_costs_for_credits: 0, + fetch_frozen_listings: None, }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs index 3b749789e20..74a944893cb 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs @@ -1,13 +1,20 @@ use crate::version::drive_versions::drive_token_method_versions::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2; -use crate::version::drive_versions::drive_token_method_versions::DriveTokenMethodVersions; +use crate::version::drive_versions::drive_token_method_versions::{ + DriveTokenMethodVersions, DriveTokenOrderEscrowMethodVersions, +}; /// Drive token methods for protocol v15+. /// /// Identical to [`super::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2`] except /// `calculate_total_tokens_balance` is bumped to `2`: the tokens the token marketplace holds in /// escrow for open listings join the identity balances and the shielded pools on the balance -/// side of the token conservation check. +/// side of the token conservation check, and `order_escrow.fetch_frozen_listings` is `Some(0)`: +/// `TokenDestroyFrozenFunds` destroys what a frozen identity's open listings hold too. pub const DRIVE_TOKEN_METHOD_VERSIONS_V3: DriveTokenMethodVersions = DriveTokenMethodVersions { calculate_total_tokens_balance: 2, + order_escrow: DriveTokenOrderEscrowMethodVersions { + fetch_frozen_listings: Some(0), + ..DRIVE_TOKEN_METHOD_VERSIONS_V2.order_escrow + }, ..DRIVE_TOKEN_METHOD_VERSIONS_V2 }; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 09e432b4e62..1338c6e4886 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -1,3 +1,4 @@ +use crate::version::drive_versions::drive_state_transition_method_versions::v5::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5; use crate::version::drive_versions::drive_token_method_versions::v3::DRIVE_TOKEN_METHOD_VERSIONS_V3; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; use crate::version::drive_versions::{ @@ -15,6 +16,9 @@ use crate::version::drive_versions::{ /// * **Token conservation** — `DRIVE_TOKEN_METHOD_VERSIONS_V3` bumps /// `calculate_total_tokens_balance` to 2, which counts the escrowed listings on the balance /// side. +/// * **Frozen listings** — `DRIVE_TOKEN_METHOD_VERSIONS_V3` reads a frozen identity's open +/// listings (`order_escrow.fetch_frozen_listings`) and `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5` +/// has `TokenDestroyFrozenFunds` destroy what they hold with its balance. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -22,7 +26,8 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { initialization: DriveInitializationMethodVersions { create_initial_state_structure: 5, // changed in v10: adds the token marketplace escrow trees }, - token: DRIVE_TOKEN_METHOD_VERSIONS_V3, // changed in v10: calculate_total_tokens_balance 2 counts the token marketplace escrow + token: DRIVE_TOKEN_METHOD_VERSIONS_V3, // changed in v10: calculate_total_tokens_balance 2 counts the token marketplace escrow; a frozen identity's listings are read + state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5, // changed in v10: TokenDestroyFrozenFunds also destroys the frozen identity's escrowed listings ..DRIVE_VERSION_V9.methods }, ..DRIVE_VERSION_V9 diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 15fb2052eac..4ba7b596e61 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -625,6 +625,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, + max_frozen_listings_destroyed_per_transition: 16, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, max_shielded_transition_actions: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index a45e349727f..a9b0bd3ee83 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -286,6 +286,10 @@ pub struct SystemLimits { /// must stay at least this where it is read. Read by the contested document create /// state validation v2 (protocol version 14) and never reached before. pub max_contenders_per_contest: u16, + /// Most open listings of a frozen identity one `TokenDestroyFrozenFunds` destroys with its + /// balance, oldest first; another destroys the next ones. Read from protocol version 15 + /// (`order_escrow.fetch_frozen_listings`) and never reached before. + pub max_frozen_listings_destroyed_per_transition: u16, // This the max redemption cycles we can process if we don't use a constant distribution // For a constant perpetual distribution this is very cheap since it's just a multiplication // For other distributions we much calculate at each cycle the rewards, so we don't want to diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 619a1890c2d..28e9f97c00a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -71,6 +71,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, + max_frozen_listings_destroyed_per_transition: 16, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 076d006808a..1342ef1bb71 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -52,6 +52,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, + max_frozen_listings_destroyed_per_transition: 16, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index b5380e1c485..2865a218206 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -54,6 +54,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, + max_frozen_listings_destroyed_per_transition: 16, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 7baa5dcdb5a..05a9639f894 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -99,6 +99,10 @@ use crate::version::system_limits::SystemLimits; /// the charters contract's `electedCharter.members` `maxItems`) and the declaration's /// `maxAddedModerators`. It joined this table in place while protocol version 14 was /// unreleased, and was backfilled into the earlier tables, whose parsers never read it. +/// * Token marketplace (protocol version 15): a `TokenDestroyFrozenFunds` destroys at most 16 +/// open listings of the frozen identity with its balance +/// (`max_frozen_listings_destroyed_per_transition`, in every table; nothing before 15 reads +/// it). /// * Contested documents (protocol version 14): a contest accepts at most 1,000 contenders /// (`max_contenders_per_contest`, backfilled into the earlier tables, whose validation never /// reads it). The end of a poll within the cap tallies and cleans up every contender in one @@ -175,6 +179,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, + max_frozen_listings_destroyed_per_transition: 16, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 68583cb7b5e..1102e98ddb2 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -174,6 +174,14 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// it keeping the stored contract's storage ownership, as `transition_to_version_14` did for /// v2. The marketplace transitions of a token that keeps transfer history write the matching /// history document. +/// +/// 10. **Frozen listings**: a `TokenDestroyFrozenFunds` also destroys what the frozen identity's +/// open listings of the token hold in escrow, the oldest first and at most +/// `max_frozen_listings_destroyed_per_transition` (16) per transition, and deletes their +/// `ask` documents. `DRIVE_TOKEN_METHOD_VERSIONS_V3` reads them +/// (`order_escrow.fetch_frozen_listings` `Some(0)`, `None` before) and +/// `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5` destroys them +/// (`token_destroy_frozen_funds_transition` 1). The recorded amount is everything destroyed. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: the token marketplace escrow trees and calculate_total_tokens_balance 2 diff --git a/packages/token-history-contract/src/lib.rs b/packages/token-history-contract/src/lib.rs index 6e5090a3806..780be608a0e 100644 --- a/packages/token-history-contract/src/lib.rs +++ b/packages/token-history-contract/src/lib.rs @@ -53,7 +53,9 @@ mod tests { fn v3_adds_the_order_types_to_v2_and_changes_nothing_else() { let v2 = v2::load_documents_schemas().expect("expected the v2 schemas"); let mut v3 = v3::load_documents_schemas().expect("expected the v3 schemas"); - let v3_types = v3.as_object_mut().expect("expected the v3 schemas to be an object"); + let v3_types = v3 + .as_object_mut() + .expect("expected the v3 schemas to be an object"); for name in ["orderPlaced", "orderFilled", "orderCancelled"] { assert!( v3_types.remove(name).is_some(), From 086cbf044aafa59b38efa2922cf8fc3aed1017ba Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 10:32:01 +0300 Subject: [PATCH 02/21] fix(drive): delete a frozen identity's listings in one batch-aware operation - New document operation DeleteDocuments: several documents of one type deleted in one conversion, each delete built against the ones before it, so index trees they share are removed and refunded. Generation 1 of the destroy-frozen-funds converter deletes the listings through it; separate DeleteDocuments each read the others as stored and left shared trees behind, unrefunded. - The converter's generation 0 is restored to its shipped implementation; generation 1 is its own function, and collects only the (id, remaining) pairs it needs instead of cloning the orders. - Both transformer routes read and bill the listings through one helper. - Tests: two listings sharing index buckets destroyed together leave the ask trees cancelling them one by one leaves and refund at least as much; dispatcher tests at protocol version 14 (nonce, burn, history, group proposal and completion) and 15; the listing read at 14 returns none at zero cost. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 137 +++++++ .../drive/tokens/order_escrow/fetch_order.rs | 28 ++ .../token_destroy_frozen_funds_transition.rs | 358 ++++++++++++++++-- .../v0/transformer.rs | 60 +-- .../src/util/batch/drive_op_batch/document.rs | 66 ++++ 5 files changed, 586 insertions(+), 63 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index e8fc4eb1532..67239e7adb2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -2083,6 +2083,11 @@ mod history { mod frozen { use super::*; use dpp::document::DocumentV0Getters; + use dpp::fee::fee_result::FeeResult; + use drive::drive::RootTree; + use drive::grovedb::query_result_type::{QueryResultElement, QueryResultType}; + use drive::grovedb::{PathQuery, Query, SizedQuery}; + use std::collections::BTreeSet; /// The contract owner (the seller) may freeze an account and destroy its frozen funds. fn make_token_tradeable_and_freezable(token_configuration: &mut TokenConfiguration) { @@ -2213,6 +2218,138 @@ mod frozen { } } + /// The fee a processed batch charged its signer, which must have executed. + fn charged(processing_result: &StateTransitionsProcessingResult) -> FeeResult { + match processing_result.execution_results().as_slice() { + [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] => { + fee_result.clone() + } + other => panic!("expected the batch to execute: {other:?}"), + } + } + + impl Market { + /// Every tree and item under the token market contract's `ask` document type: its + /// documents and their index entries, by path. + fn ask_trees(&self) -> BTreeSet>> { + let platform_version = PlatformVersion::latest(); + let root = vec![ + vec![RootTree::DataContractDocuments as u8], + SystemDataContract::TokenMarket.id().to_vec(), + vec![1u8], + TokenOrderSide::Ask.document_type_name().as_bytes().to_vec(), + ]; + let mut paths = BTreeSet::new(); + let mut pending = vec![root]; + while let Some(path) = pending.pop() { + let mut query = Query::new(); + query.insert_all(); + let (rows, _) = self + .platform + .drive + .grove + .query_raw( + &PathQuery::new(path.clone(), SizedQuery::new(query, None, None)), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + &platform_version.drive.grove_version, + ) + .value + .expect("expected to read the ask trees"); + for row in rows.elements { + let QueryResultElement::KeyElementPairResultItem((key, element)) = row else { + panic!("expected a key and an element"); + }; + let mut child = path.clone(); + child.push(key); + if element.is_any_tree() { + pending.push(child.clone()); + } + paths.insert(child); + } + } + paths + } + + /// The seller destroys the buyer's frozen funds, and the fee it was charged. + async fn destroy_buyers_frozen_funds_charged(&mut self) -> FeeResult { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let destroy = BatchTransition::new_token_destroy_frozen_funds_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + self.buyer.id(), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the destroy"); + charged(&self.process_at(&destroy, &at_time(1_000_000))) + } + } + + /// Two listings at one price placed in one block share their `ask` index trees: the price + /// bucket of `book` and `depth`, the `$createdAt` bucket of `book`, and the owner and token + /// buckets of `byOwner`. A destroy deletes both in one batch: each delete is built against + /// the other, so the trees they shared are removed. The `ask` trees left are the ones + /// cancelling the two listings one by one leaves, and the frozen owner gets back at least + /// the storage those cancels refund (the destroy also shrinks the balance it burns to 0, where + /// a cancel grows it). Built as separate deletes, 16 of the 24 trees stayed instead of 3, and + /// the refund was about half. + #[tokio::test] + async fn should_remove_and_refund_the_index_trees_destroyed_listings_shared() { + let mut destroyed = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); + destroyed.give_buyer(10_000).await; + destroyed.buyer_lists(3_000, 1).await; + destroyed.buyer_lists(2_000, 1).await; + let trees_with_listings = destroyed.ask_trees(); + destroyed.freeze_buyer().await; + let fee = destroyed.destroy_buyers_frozen_funds_charged().await; + let destroy_refund = fee + .fee_refunds + .calculate_refunds_amount_for_identity(destroyed.buyer.id()) + .unwrap_or_default(); + + let mut cancelled = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); + cancelled.give_buyer(10_000).await; + let first = cancelled.buyer_lists(3_000, 1).await; + let second = cancelled.buyer_lists(2_000, 1).await; + assert_eq!(cancelled.ask_trees(), trees_with_listings); + let mut cancel_refunds = 0; + for order_id in [first, second] { + let cancel = cancelled.cancel(order_id, true).await; + let fee = charged(&cancelled.process(&cancel)); + cancel_refunds += fee + .fee_refunds + .calculate_refunds_amount_for_identity(cancelled.buyer.id()) + .unwrap_or_default(); + } + + let trees_left = destroyed.ask_trees(); + assert_eq!(trees_left, cancelled.ask_trees()); + assert!( + trees_left.len() < trees_with_listings.len(), + "the shared trees are removed: {} left of {}", + trees_left.len(), + trees_with_listings.len() + ); + assert!( + destroy_refund >= cancel_refunds, + "the destroy refunds {destroy_refund}, the cancels {cancel_refunds}" + ); + } + #[tokio::test] async fn should_destroy_a_frozen_identitys_listings_with_its_balance() { let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs index fd2e8c3ef8b..93e834ac716 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs @@ -197,3 +197,31 @@ impl Drive { Ok((listings, cost)) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::epoch::Epoch; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + + /// Before protocol version 15 a destroy of frozen funds reads no listings: on a chain with no + /// marketplace state (no escrow trees, no token market contract) the read returns none and + /// costs nothing. + #[test] + fn should_read_no_frozen_listings_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let (listings, cost) = drive + .fetch_frozen_token_listings( + Identifier::from([5; 32]), + Identifier::from([6; 32]), + &Epoch::new(0).expect("expected an epoch"), + None, + platform_version, + ) + .expect("expected the read to be skipped"); + assert!(listings.is_empty()); + assert_eq!(cost, 0); + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs index ca480e570d9..fdbccb722ef 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs @@ -3,9 +3,9 @@ use dpp::group::action_event::GroupActionEvent; use dpp::group::group_action::GroupAction; use dpp::group::group_action::v0::GroupActionV0; use dpp::group::GroupStateTransitionResolvedInfo; +use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; use dpp::tokens::token_event::TokenEvent; -use dpp::tokens::token_order::TokenOrder; use dpp::tokens::token_order_side::TokenOrderSide; use platform_version::version::PlatformVersion; use crate::error::drive::DriveError; @@ -14,9 +14,9 @@ use crate::state_transition_action::action_convert_to_operations::batch::DriveHi use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; -use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; use crate::util::batch::drive_op_batch::{GroupOperationType, TokenOperationType}; use crate::util::batch::DriveOperation::{DocumentOperation, GroupOperation, IdentityOperation, TokenOperation}; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransitionAction { fn into_high_level_batch_drive_operations<'b>( @@ -32,10 +32,76 @@ impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransition .convert_to_high_level_operations .token_destroy_frozen_funds_transition { - 0 => self.destroy_frozen_funds_operations(owner_id, false), + 0 => { + let data_contract_id = self.base().data_contract_id(); + + let identity_contract_nonce = self.base().identity_contract_nonce(); + + let mut ops = vec![IdentityOperation( + IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }, + )]; + + if let Some(GroupStateTransitionResolvedInfo { + group_contract_position, + action_id, + action_is_proposer, + signer_power, + .. + }) = self.base().store_in_group() + { + let event = + TokenEvent::DestroyFrozenFunds(self.frozen_identity_id(), self.amount(), self.public_note().cloned()); + + let initialize_with_insert_action_info = if *action_is_proposer { + Some(GroupAction::V0(GroupActionV0 { + contract_id: self.base().data_contract_id(), + proposer_id: owner_id, + token_contract_position: self.base().token_position(), + event: GroupActionEvent::TokenEvent(event), + })) + } else { + None + }; + + ops.push(GroupOperation(GroupOperationType::AddGroupAction { + contract_id: data_contract_id, + group_contract_position: *group_contract_position, + initialize_with_insert_action_info, + action_id: *action_id, + signer_identity_id: owner_id, + signer_power: *signer_power, + closes_group_action: self.base().perform_action(), + })); + } + + if self.base().perform_action() { + ops.push(TokenOperation(TokenOperationType::TokenBurn { + token_id: self.token_id(), + identity_balance_holder_id: self.frozen_identity_id(), + burn_amount: self.amount(), + })); + + ops.push(TokenOperation(TokenOperationType::TokenHistory { + token_id: self.token_id(), + owner_id, + nonce: identity_contract_nonce, + event: TokenEvent::DestroyFrozenFunds( + self.frozen_identity_id(), + self.amount(), + self.public_note_owned(), + ), + })); + } + + Ok(ops) + } // From protocol version 15: also destroys what the frozen identity's open listings // hold in escrow, and deletes their `ask` documents. - 1 => self.destroy_frozen_funds_operations(owner_id, true), + 1 => self.destroy_frozen_funds_and_listings_operations_v1(owner_id), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "TokenDestroyFrozenFundsTransitionAction::into_high_level_document_drive_operations" .to_string(), @@ -47,33 +113,32 @@ impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransition } impl TokenDestroyFrozenFundsTransitionAction { - /// The operations of a destroy of frozen funds. Without `destroy_listings` (generation 0) - /// the frozen balance alone is burned, which is all there is before the marketplace. With it - /// (generation 1) the frozen identity's open listings the transformer read are destroyed - /// too: what they hold in escrow leaves the supply with the balance, their `ask` documents - /// are deleted, and the recorded amount is everything destroyed. - fn destroy_frozen_funds_operations<'b>( + /// Generation 1 (protocol version 15): generation 0's operations, and with them the + /// destruction of the frozen identity's open listings the transformer read. One + /// `TokenDestroyFrozenHolding` burns the balance, empties each listing's escrow and lowers + /// the supply by everything destroyed; one `DeleteDocuments` deletes the listings' `ask` + /// documents, each built against the ones before it so index trees they share are removed + /// and refunded; the history and group-action events record everything destroyed. Without + /// listings the operations are generation 0's. + fn destroy_frozen_funds_and_listings_operations_v1<'b>( self, owner_id: Identifier, - destroy_listings: bool, ) -> Result>, Error> { let data_contract_id = self.base().data_contract_id(); let identity_contract_nonce = self.base().identity_contract_nonce(); - let listings: Vec = if destroy_listings { - self.escrowed_listings().to_vec() - } else { - vec![] - }; - let destroyed_amount = if listings.is_empty() { - self.amount() - } else { + // What the destroy needs of each listing: its id and the tokens it holds. + let listings: Vec<(Identifier, TokenAmount)> = self + .escrowed_listings() + .iter() + .map(|listing| (listing.id, listing.remaining)) + .collect(); + let destroyed_amount = self.total_destroyed() .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( "a frozen holding is more tokens than a u64 holds", - )))? - }; + )))?; let mut ops = vec![IdentityOperation( IdentityOperationType::UpdateIdentityContractNonce { @@ -127,33 +192,27 @@ impl TokenDestroyFrozenFundsTransitionAction { burn_amount: self.amount(), })); } else { + let token_market_contract = self.token_market_contract().cloned().ok_or( + Error::Drive(DriveError::CorruptedCodeExecution( + "listings to destroy come with the token market contract", + )), + )?; + let document_ids = listings.iter().map(|(id, _)| *id).collect(); ops.push(TokenOperation( TokenOperationType::TokenDestroyFrozenHolding { token_id: self.token_id(), holder_id: self.frozen_identity_id(), balance_amount: self.amount(), - listings: listings - .iter() - .map(|listing| (listing.id, listing.remaining)) - .collect(), + listings, }, )); - let token_market_contract = self.token_market_contract().cloned().ok_or( - Error::Drive(DriveError::CorruptedCodeExecution( - "listings to destroy come with the token market contract", - )), - )?; - for listing in &listings { - ops.push(DocumentOperation(DocumentOperationType::DeleteDocument { - document_id: listing.id, - contract_info: DataContractInfo::DataContractFetchInfo( - token_market_contract.clone(), - ), - document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( - TokenOrderSide::Ask.document_type_name(), - ), - })); - } + ops.push(DocumentOperation(DocumentOperationType::DeleteDocuments { + document_ids, + contract_info: DataContractInfo::DataContractFetchInfo(token_market_contract), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( + TokenOrderSide::Ask.document_type_name(), + ), + })); } ops.push(TokenOperation(TokenOperationType::TokenHistory { @@ -171,3 +230,220 @@ impl TokenDestroyFrozenFundsTransitionAction { Ok(ops) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::drive::contract::DataContractFetchInfo; + use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{ + TokenBaseTransitionAction, TokenBaseTransitionActionV0, + }; + use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionActionV0; + use dpp::data_contract::group::v0::GroupV0; + use dpp::data_contract::group::Group; + use dpp::tokens::token_order::TokenOrder; + use std::collections::BTreeMap; + use std::sync::Arc; + + const TOKEN_ID: [u8; 32] = [0xCC; 32]; + const SIGNER: [u8; 32] = [0xAA; 32]; + const FROZEN: [u8; 32] = [0xEE; 32]; + const NONCE: u64 = 4; + const AMOUNT: TokenAmount = 999; + + fn pv14() -> &'static PlatformVersion { + PlatformVersion::get(14).expect("expected protocol version 14") + } + + /// The signer proposes (`proposer`) or completes a group action, which performs it when + /// `perform_action`. + fn in_group(proposer: bool) -> GroupStateTransitionResolvedInfo { + GroupStateTransitionResolvedInfo { + group_contract_position: 0, + group: Group::V0(GroupV0 { + members: BTreeMap::from([(Identifier::from(SIGNER), 1)]), + required_power: 2, + }), + action_id: Identifier::from([0x77; 32]), + action_is_proposer: proposer, + signer_power: 1, + } + } + + fn listing(id: u8, remaining: TokenAmount) -> TokenOrder { + TokenOrder { + id: Identifier::from([id; 32]), + side: TokenOrderSide::Ask, + maker_id: Identifier::from(FROZEN), + token_id: Identifier::from(TOKEN_ID), + price: 1, + amount: remaining, + remaining, + min_fill: 1, + expires_at: None, + escrow: 0, + decimals: 0, + } + } + + fn action( + group: Option, + perform_action: bool, + escrowed_listings: Vec, + ) -> TokenDestroyFrozenFundsTransitionAction { + let protocol_version = PlatformVersion::latest().protocol_version; + let contract = Arc::new(DataContractFetchInfo::dpns_contract_fixture( + protocol_version, + )); + let token_market_contract = (!escrowed_listings.is_empty()).then(|| Arc::clone(&contract)); + TokenDestroyFrozenFundsTransitionActionV0 { + base: TokenBaseTransitionAction::V0(TokenBaseTransitionActionV0 { + token_id: Identifier::from(TOKEN_ID), + identity_contract_nonce: NONCE, + token_contract_position: 0, + data_contract: contract, + store_in_group: group.map(|info| (info, None)), + perform_action, + }), + frozen_identity_id: Identifier::from(FROZEN), + amount: AMOUNT, + escrowed_listings, + token_market_contract, + public_note: Some("note".to_string()), + } + .into() + } + + fn convert( + action: TokenDestroyFrozenFundsTransitionAction, + platform_version: &PlatformVersion, + ) -> Vec> { + action + .into_high_level_batch_drive_operations( + &Epoch::new(0).expect("expected an epoch"), + Identifier::from(SIGNER), + platform_version, + ) + .expect("expected the operations") + } + + fn is_nonce(operation: &DriveOperation) -> bool { + matches!( + operation, + IdentityOperation(IdentityOperationType::UpdateIdentityContractNonce { + identity_id, + nonce: NONCE, + .. + }) if *identity_id == SIGNER + ) + } + + fn is_burn_of_the_balance(operation: &DriveOperation) -> bool { + matches!( + operation, + TokenOperation(TokenOperationType::TokenBurn { + identity_balance_holder_id, + burn_amount: AMOUNT, + .. + }) if *identity_balance_holder_id == Identifier::from(FROZEN) + ) + } + + fn is_history_of(operation: &DriveOperation, destroyed: TokenAmount) -> bool { + matches!( + operation, + TokenOperation(TokenOperationType::TokenHistory { + nonce: NONCE, + event: TokenEvent::DestroyFrozenFunds(frozen, amount, Some(note)), + .. + }) if *frozen == Identifier::from(FROZEN) && *amount == destroyed && note == "note" + ) + } + + /// Generation 0, which protocol version 14 selects: the signer's nonce, the burn of the + /// frozen balance and its history, and nothing of any listing even when the action carries + /// some. + #[test] + fn should_burn_the_frozen_balance_alone_at_protocol_version_14() { + for listings in [vec![], vec![listing(1, 300), listing(2, 200)]] { + let operations = convert(action(None, true, listings), pv14()); + assert_eq!(operations.len(), 3, "{operations:?}"); + assert!(is_nonce(&operations[0])); + assert!(is_burn_of_the_balance(&operations[1])); + assert!(is_history_of(&operations[2], AMOUNT)); + } + } + + /// Generation 0 in a group: a proposal that does not yet perform the action records the + /// group action with its event and burns nothing; the signature that completes it records + /// its power, closes the action and burns. + #[test] + fn should_record_the_group_action_at_protocol_version_14() { + let operations = convert(action(Some(in_group(true)), false, vec![]), pv14()); + assert_eq!(operations.len(), 2, "{operations:?}"); + assert!(is_nonce(&operations[0])); + assert!(matches!( + &operations[1], + GroupOperation(GroupOperationType::AddGroupAction { + initialize_with_insert_action_info: Some(GroupAction::V0(GroupActionV0 { + event: GroupActionEvent::TokenEvent(TokenEvent::DestroyFrozenFunds( + _, + AMOUNT, + _ + )), + .. + })), + closes_group_action: false, + signer_power: 1, + .. + }) + )); + + let operations = convert(action(Some(in_group(false)), true, vec![]), pv14()); + assert_eq!(operations.len(), 4, "{operations:?}"); + assert!(is_nonce(&operations[0])); + assert!(matches!( + &operations[1], + GroupOperation(GroupOperationType::AddGroupAction { + initialize_with_insert_action_info: None, + closes_group_action: true, + .. + }) + )); + assert!(is_burn_of_the_balance(&operations[2])); + assert!(is_history_of(&operations[3], AMOUNT)); + } + + /// Generation 1, which protocol version 15 selects: without listings, generation 0's + /// operations; with listings, one holding destroy of the balance and every listing's escrow, + /// one `DeleteDocuments` of their `ask` documents and the history of everything destroyed. + #[test] + fn should_destroy_the_listings_with_the_balance_at_protocol_version_15() { + let latest = PlatformVersion::latest(); + let operations = convert(action(None, true, vec![]), latest); + assert_eq!(operations.len(), 3, "{operations:?}"); + assert!(is_burn_of_the_balance(&operations[1])); + assert!(is_history_of(&operations[2], AMOUNT)); + + let operations = convert( + action(None, true, vec![listing(1, 300), listing(2, 200)]), + latest, + ); + assert_eq!(operations.len(), 4, "{operations:?}"); + assert!(is_nonce(&operations[0])); + assert!(matches!( + &operations[1], + TokenOperation(TokenOperationType::TokenDestroyFrozenHolding { + balance_amount: AMOUNT, + listings, + .. + }) if *listings == vec![(Identifier::from([1; 32]), 300), (Identifier::from([2; 32]), 200)] + )); + assert!(matches!( + &operations[2], + DocumentOperation(DocumentOperationType::DeleteDocuments { document_ids, .. }) + if *document_ids == vec![Identifier::from([1; 32]), Identifier::from([2; 32])] + )); + assert!(is_history_of(&operations[3], AMOUNT + 500)); + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs index 5413d5e012c..a81da66a06a 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs @@ -6,6 +6,7 @@ use dpp::consensus::state::token::IdentityDoesNotHaveEnoughTokenBalanceError; use dpp::identifier::Identifier; use dpp::state_transition::batch_transition::token_destroy_frozen_funds_transition::v0::TokenDestroyFrozenFundsTransitionV0; use dpp::ProtocolError; +use dpp::tokens::token_order::TokenOrder; use crate::drive::contract::DataContractFetchInfo; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::v0::TokenDestroyFrozenFundsTransitionActionV0; @@ -148,22 +149,15 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } }; - // From protocol version 15 the frozen identity's open listings are destroyed with its - // balance: what they hold sits in escrow, out of the balance's reach. Earlier versions - // read none. - let (escrowed_listings, listings_read_cost) = drive.fetch_frozen_token_listings( + let (escrowed_listings, token_market_contract) = read_frozen_listings( + drive, base.token_id(), frozen_identity_id, - &block_info.epoch, + block_info, + &mut fee_result, transaction, platform_version, )?; - fee_result.processing_fee = fee_result.processing_fee.saturating_add(listings_read_cost); - let token_market_contract = if escrowed_listings.is_empty() { - None - } else { - Some(drive.token_market_contract_fetch_info(platform_version)?) - }; Ok(( BatchedTransitionAction::TokenAction(TokenTransitionAction::DestroyFrozenFundsAction( @@ -315,22 +309,15 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } }; - // From protocol version 15 the frozen identity's open listings are destroyed with its - // balance: what they hold sits in escrow, out of the balance's reach. Earlier versions - // read none. - let (escrowed_listings, listings_read_cost) = drive.fetch_frozen_token_listings( + let (escrowed_listings, token_market_contract) = read_frozen_listings( + drive, base.token_id(), *frozen_identity_id, - &block_info.epoch, + block_info, + &mut fee_result, transaction, platform_version, )?; - fee_result.processing_fee = fee_result.processing_fee.saturating_add(listings_read_cost); - let token_market_contract = if escrowed_listings.is_empty() { - None - } else { - Some(drive.token_market_contract_fetch_info(platform_version)?) - }; Ok(( BatchedTransitionAction::TokenAction(TokenTransitionAction::DestroyFrozenFundsAction( @@ -350,6 +337,35 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } } +/// The frozen identity's open listings a destroy takes with its balance and, when there are +/// any, the token market contract their `ask` documents are deleted from. From protocol version +/// 15 the listings are destroyed with the balance, as what they hold sits in escrow, out of the +/// balance's reach; earlier versions read none. The read is billed to `fee_result`. +fn read_frozen_listings( + drive: &Drive, + token_id: Identifier, + frozen_identity_id: Identifier, + block_info: &BlockInfo, + fee_result: &mut FeeResult, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result<(Vec, Option>), Error> { + let (listings, read_cost) = drive.fetch_frozen_token_listings( + token_id, + frozen_identity_id, + &block_info.epoch, + transaction, + platform_version, + )?; + fee_result.processing_fee = fee_result.processing_fee.saturating_add(read_cost); + let token_market_contract = if listings.is_empty() { + None + } else { + Some(drive.token_market_contract_fetch_info(platform_version)?) + }; + Ok((listings, token_market_contract)) +} + #[cfg(test)] #[allow(clippy::unnecessary_literal_unwrap)] mod tests { diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs index da0c12b3877..9ae0579ae61 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs @@ -119,6 +119,20 @@ pub enum DocumentOperationType<'a> { /// Document type document_type_info: DocumentTypeInfo<'a>, }, + /// Deletes several documents of one document type in one conversion: each delete is built + /// against the deletes already built, so an index tree the documents share (the same indexed + /// value, the same `$createdAt` bucket) is seen emptied by the last of them and removed, and + /// whoever paid for it gets its storage back. Separate `DeleteDocument`s would each read the + /// others as still stored and leave the emptied tree behind. Used by the token marketplace's + /// destroy of a frozen identity's listings (protocol version 15). + DeleteDocuments { + /// The ids of the documents, deleted in this order + document_ids: Vec, + /// Data Contract info to potentially be resolved if needed + contract_info: DataContractInfo<'a>, + /// Document type + document_type_info: DocumentTypeInfo<'a>, + }, /// Deletes a document without consulting `canBeDeleted`, which rules what the document's /// own owner may do. Used for a deletion on behalf of the contract's moderators (the /// caller has checked that the document type sets `moderatorAbilities.delete`), for a @@ -301,6 +315,25 @@ impl DocumentOperationType<'_> { matches!(self, Self::AddDocument { .. }), ) } + Self::DeleteDocuments { + document_ids, + contract_info, + document_type_info, + } => { + let resolved = contract_info.clone().resolve( + drive, + block_info, + transaction, + &mut vec![], + platform_version, + )?; + let contract = resolved.as_ref(); + let document_type = document_type_info.clone().resolve(contract)?; + for _ in document_ids { + visit(contract, document_type, false)?; + } + Ok(()) + } Self::MultipleDocumentOperationsForSameContractDocumentType { document_operations, } => { @@ -594,6 +627,39 @@ impl DocumentOperationType<'_> { platform_version, ) } + DocumentOperationType::DeleteDocuments { + document_ids, + contract_info, + document_type_info, + } => { + let mut drive_operations: Vec = vec![]; + let contract_resolved_info = contract_info.resolve( + drive, + block_info, + transaction, + &mut drive_operations, + platform_version, + )?; + let contract = contract_resolved_info.as_ref(); + let document_type = document_type_info.resolve(contract)?; + // Each delete sees the deletes before it as pending, so a tree they empty + // together is removed by the last of them. + for document_id in document_ids { + let mut operations = drive + .delete_document_for_contract_operations_without_ttl_drain( + document_id, + contract, + document_type, + Some(&mut drive_operations), + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + platform_version, + )?; + drive_operations.append(&mut operations); + } + Ok(drive_operations) + } DocumentOperationType::ForceDeleteDocument { document_id, contract_info, From afcfa475ea05ca4385dde6ad3116ab3490322b23 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 11:30:14 +0300 Subject: [PATCH 03/21] fix(drive): build each operation of a protocol 15 batch against the batch's earlier operations apply_drive_operations 2 (protocol version 15, unreleased, extended in place) builds every operation against the operations of its GroveDB batch built before it and appends its own (DriveOperation::append_low_level_drive_operations_after_ttl_drain). Every document insert, update and deletion, DeleteDocuments included, gets them as previous_batch_operations: one batch destroying a frozen identity's listings of two tokens removes the owner's shared byOwner bucket and refunds it, and an insert into a bucket an earlier deletion of the batch empties keeps the tree. Protocol version 14 and earlier keep generation 1. Co-Authored-By: Claude Opus 5.5 --- .../src/util/batch/drive_op_batch/document.rs | 278 ++++++++++++++++++ .../apply_drive_operations/v2/mod.rs | 254 +++++++++++++++- .../src/util/batch/drive_op_batch/mod.rs | 40 +++ 3 files changed, 559 insertions(+), 13 deletions(-) diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs index 9ae0579ae61..a2fce605b12 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs @@ -381,6 +381,284 @@ impl DocumentOperationType<'_> { } } + /// Converts a member of a batch whose document TTL cleanup is complete against the operations + /// the batch built before it, and appends its own to them (protocol version 15, + /// `apply_drive_operations` 2). Every document insert, update and deletion is built with the + /// batch's earlier operations as pending: a deletion does not take a tree an earlier write + /// of the batch fills for empty, two deletions that empty a tree together remove it, and an + /// insert into a tree an earlier deletion of the batch empties keeps the tree. + /// `into_low_level_drive_operations_after_ttl_drain` builds an operation on its own. + pub(crate) fn append_low_level_drive_operations_after_ttl_drain( + self, + drive: &Drive, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + block_info: &BlockInfo, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match self { + DocumentOperationType::AddDocument { + owned_document_info, + contract_info, + document_type_info, + override_document, + } => { + let contract_resolved_info = contract_info.resolve( + drive, + block_info, + transaction, + batch_operations, + platform_version, + )?; + let contract = contract_resolved_info.as_ref(); + let document_type = document_type_info.resolve(contract)?; + let operations = drive.add_document_for_contract_operations_without_ttl_drain( + DocumentAndContractInfo { + owned_document_info, + contract, + document_type, + }, + override_document, + block_info, + &mut Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + } + DocumentOperationType::UpdateDocument { + owned_document_info, + contract_info, + document_type_info, + } => { + let contract_resolved_info = contract_info.resolve( + drive, + block_info, + transaction, + batch_operations, + platform_version, + )?; + let contract = contract_resolved_info.as_ref(); + let document_type = document_type_info.resolve(contract)?; + let operations = drive.update_document_for_contract_operations_without_ttl_drain( + DocumentAndContractInfo { + owned_document_info, + contract, + document_type, + }, + block_info, + &mut Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + } + DocumentOperationType::AddDocumentAndDeleteConsumed { + owned_document_info, + contract_info, + document_type_info, + consumed_documents, + } => { + let contract_resolved_info = contract_info.resolve( + drive, + block_info, + transaction, + batch_operations, + platform_version, + )?; + let contract = contract_resolved_info.as_ref(); + let document_type = document_type_info.resolve(contract)?; + let operations = drive.add_document_for_contract_operations_without_ttl_drain( + DocumentAndContractInfo { + owned_document_info, + contract, + document_type, + }, + false, + block_info, + &mut Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + for (document_id, document_type_name) in consumed_documents { + let consumed_document_type = + DocumentTypeInfo::DocumentTypeName(document_type_name).resolve(contract)?; + let operations = drive.force_delete_document_for_contract_operations( + document_id, + contract, + consumed_document_type, + Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + } + } + DocumentOperationType::DeleteDocument { + document_id, + contract_info, + document_type_info, + } => { + Self::append_deletions( + drive, + vec![document_id], + contract_info, + document_type_info, + false, + estimated_costs_only_with_layer_info, + block_info, + batch_operations, + transaction, + platform_version, + )?; + } + DocumentOperationType::DeleteDocuments { + document_ids, + contract_info, + document_type_info, + } => { + Self::append_deletions( + drive, + document_ids, + contract_info, + document_type_info, + false, + estimated_costs_only_with_layer_info, + block_info, + batch_operations, + transaction, + platform_version, + )?; + } + DocumentOperationType::ForceDeleteDocument { + document_id, + contract_info, + document_type_info, + } => { + Self::append_deletions( + drive, + vec![document_id], + contract_info, + document_type_info, + true, + estimated_costs_only_with_layer_info, + block_info, + batch_operations, + transaction, + platform_version, + )?; + } + DocumentOperationType::DeleteIndexOnlyDocument { + document_id, + owner_id, + data, + contract_info, + document_type_info, + } => { + let contract_resolved_info = contract_info.resolve( + drive, + block_info, + transaction, + batch_operations, + platform_version, + )?; + let contract = contract_resolved_info.as_ref(); + let document_type = document_type_info.resolve(contract)?; + let document = Drive::index_only_document_from_values(document_id, owner_id, data)?; + let operations = drive + .delete_index_only_document_for_contract_operations_without_ttl_drain( + document, + contract, + document_type, + Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + } + // A contested insert, a withdrawal, a document history entry and a group of + // operations for one document type: built as before, after the batch's earlier + // operations. + operation => { + let operations = operation.into_low_level_drive_operations_after_ttl_drain( + drive, + estimated_costs_only_with_layer_info, + block_info, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + } + } + Ok(()) + } + + /// Appends the deletions of `document_ids`, each built against the batch's operations so + /// far; `force` skips the owner's `canBeDeleted` guard (`ForceDeleteDocument`). + #[allow(clippy::too_many_arguments)] + fn append_deletions( + drive: &Drive, + document_ids: Vec, + contract_info: DataContractInfo, + document_type_info: DocumentTypeInfo, + force: bool, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + block_info: &BlockInfo, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let contract_resolved_info = contract_info.resolve( + drive, + block_info, + transaction, + batch_operations, + platform_version, + )?; + let contract = contract_resolved_info.as_ref(); + let document_type = document_type_info.resolve(contract)?; + for document_id in document_ids { + let operations = if force { + drive.force_delete_document_for_contract_operations( + document_id, + contract, + document_type, + Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + platform_version, + )? + } else { + drive.delete_document_for_contract_operations_without_ttl_drain( + document_id, + contract, + document_type, + Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + platform_version, + )? + }; + batch_operations.extend(operations); + } + Ok(()) + } + pub(crate) fn into_low_level_drive_operations_after_ttl_drain( self, drive: &Drive, 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 index 04c37cf44d2..1a5232e6038 100644 --- 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 @@ -29,7 +29,13 @@ impl Drive { /// prefunded specialized balances trees) lowered the batch's estimate of that layer. Here /// each operation is lowered with a map of its own, merged into the batch's keeping the /// deeper of two descriptions of one layer, so the estimate does not depend on the order of - /// the batch's operations. Applying a batch is generation 1's. + /// the batch's operations. + /// + /// Each operation is also built against the operations of its GroveDB batch built before it + /// ([`DriveOperation::append_low_level_drive_operations_after_ttl_drain`]): a document + /// deletion sees the batch's earlier writes as pending, so two deletions emptying an index + /// tree together remove it and refund it, and an insert into a tree an earlier deletion of + /// the batch empties keeps the tree. #[inline(always)] pub(crate) fn apply_drive_operations_v2( &self, @@ -112,37 +118,40 @@ impl Drive { let forfeited = separates_forfeited_operations && matches!(drive_op, DriveOperation::DocumentOperation(_)); - // An estimate lowers each operation into layer descriptions of its own and merges - // them into the batch's, keeping the deeper of two descriptions of one layer: what - // an operation registers never lowers what another registered, whichever comes + // Each operation is built against the operations of its GroveDB batch built before + // it, and appended to them: a document deletion sees the batch's other writes as + // pending. An estimate lowers each operation into layer descriptions of its own and + // merges them into the batch's, keeping the deeper of two descriptions of one layer: + // what an operation registers never lowers what another registered, whichever comes // first. - let mut converted = match estimated_costs_only_with_layer_info.as_mut() { - None => drive_op.into_low_level_drive_operations_after_ttl_drain( + let batch_operations = if forfeited { + &mut forfeited_low_level_operations + } else { + &mut low_level_operations + }; + match estimated_costs_only_with_layer_info.as_mut() { + None => drive_op.append_low_level_drive_operations_after_ttl_drain( self, &mut None, block_info, + batch_operations, transaction, platform_version, )?, Some(batch_layers) => { let mut operation_layers = Some(HashMap::new()); - let converted = drive_op.into_low_level_drive_operations_after_ttl_drain( + drive_op.append_low_level_drive_operations_after_ttl_drain( self, &mut operation_layers, block_info, + batch_operations, transaction, platform_version, )?; for (path, layer) in operation_layers.unwrap_or_default() { insert_estimated_layer_keeping_deeper(batch_layers, path, layer); } - converted } - }; - if forfeited { - forfeited_low_level_operations.append(&mut converted); - } else { - low_level_operations.append(&mut converted); } } @@ -300,4 +309,223 @@ mod tests { .max(deep_first_before.processing_fee) ); } + + mod batch_document_writes { + use crate::drive::{Drive, RootTree}; + use crate::util::batch::{DocumentOperationType, DriveOperation}; + use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; + use crate::util::object_size_info::{ + DataContractInfo, DocumentAndContractInfo, DocumentTypeInfo, OwnedDocumentInfo, + }; + 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::DataContract; + use dpp::document::{Document, DocumentV0}; + use dpp::identifier::Identifier; + use dpp::platform_value::Value; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + use grovedb::query_result_type::{QueryResultElement, QueryResultType}; + use grovedb::{PathQuery, Query, SizedQuery}; + use std::collections::{BTreeMap, BTreeSet}; + + const OWNER: [u8; 32] = [7; 32]; + + fn market() -> (Drive, DataContract) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = + load_system_data_contract(SystemDataContract::TokenMarket, platform_version) + .expect("expected the token market contract"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("expected to store the token market contract"); + (drive, contract) + } + + /// An `ask` of `OWNER` for `token` at price 100 and decimals 8. + fn ask(id: u8, token: u8) -> Document { + DocumentV0 { + contract_version: None, + id: Identifier::from([id; 32]), + owner_id: Identifier::from(OWNER), + properties: BTreeMap::from([ + ("tokenId".to_string(), Value::Identifier([token; 32])), + ("price".to_string(), Value::I64(100)), + ("amount".to_string(), Value::I64(10)), + ("remaining".to_string(), Value::I64(10)), + ("minFill".to_string(), Value::I64(1)), + ("decimals".to_string(), Value::I64(8)), + ]), + revision: Some(1), + created_at: Some(1_000), + updated_at: Some(1_000), + transferred_at: None, + created_at_block_height: Some(1), + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + moderated_at: None, + moderated_by: None, + } + .into() + } + + fn store(drive: &Drive, contract: &DataContract, document: &Document) { + let platform_version = PlatformVersion::latest(); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((document, None)), + owner_id: Some(OWNER), + }, + contract, + document_type: contract + .document_type_for_name("ask") + .expect("expected the ask type"), + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("expected to store the ask"); + } + + fn delete(contract: &DataContract, ids: &[u8]) -> DriveOperation<'static> { + DriveOperation::DocumentOperation(DocumentOperationType::DeleteDocuments { + document_ids: ids.iter().map(|id| Identifier::from([*id; 32])).collect(), + contract_info: DataContractInfo::OwnedDataContract(contract.clone()), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr("ask"), + }) + } + + fn apply(drive: &Drive, operations: Vec) { + drive + .apply_drive_operations( + operations, + true, + &BlockInfo::default(), + None, + PlatformVersion::latest(), + None, + ) + .expect("expected the batch to apply"); + } + + /// Every tree and item under the `ask` document type, by path. + fn ask_trees(drive: &Drive) -> BTreeSet>> { + let platform_version = PlatformVersion::latest(); + let root = vec![ + vec![RootTree::DataContractDocuments as u8], + SystemDataContract::TokenMarket.id().to_vec(), + vec![1u8], + b"ask".to_vec(), + ]; + let mut paths = BTreeSet::new(); + let mut pending = vec![root]; + while let Some(path) = pending.pop() { + let mut query = Query::new(); + query.insert_all(); + let (rows, _) = drive + .grove + .query_raw( + &PathQuery::new(path.clone(), SizedQuery::new(query, None, None)), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + &platform_version.drive.grove_version, + ) + .value + .expect("expected to read the ask trees"); + for row in rows.elements { + let QueryResultElement::KeyElementPairResultItem((key, element)) = row else { + panic!("expected a key and an element"); + }; + let mut child = path.clone(); + child.push(key); + if element.is_any_tree() { + pending.push(child.clone()); + } + paths.insert(child); + } + } + paths + } + + /// One owner's asks of two tokens share the owner's `byOwner` bucket. Deleted by two + /// operations of one batch, each built against the other, the bucket is removed: the + /// `ask` trees left are those deleting the two in two batches leaves. + #[test] + fn should_remove_a_tree_two_operations_of_one_batch_empty_together() { + let (drive, contract) = market(); + store(&drive, &contract, &ask(1, 0xA1)); + store(&drive, &contract, &ask(2, 0xA2)); + let with_asks = ask_trees(&drive); + apply( + &drive, + vec![delete(&contract, &[1]), delete(&contract, &[2])], + ); + + let (separately, contract) = market(); + store(&separately, &contract, &ask(1, 0xA1)); + store(&separately, &contract, &ask(2, 0xA2)); + apply(&separately, vec![delete(&contract, &[1])]); + apply(&separately, vec![delete(&contract, &[2])]); + + let left = ask_trees(&drive); + assert_eq!(left, ask_trees(&separately)); + assert!(left.len() < with_asks.len()); + assert!( + !left + .iter() + .any(|path| path.iter().any(|key| key.as_slice() == OWNER.as_slice())), + "no tree keyed by the owner stays" + ); + } + + /// A deletion that empties a bucket and an insert into the same bucket in one batch: the + /// insert cancels the removal, and the `ask` trees are those of the new ask alone. + #[test] + fn should_insert_into_a_tree_an_earlier_deletion_of_the_batch_empties() { + let (drive, contract) = market(); + store(&drive, &contract, &ask(1, 0xA1)); + let replacement = ask(2, 0xA1); + apply( + &drive, + vec![ + delete(&contract, &[1]), + DriveOperation::DocumentOperation(DocumentOperationType::AddDocument { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&replacement, None)), + owner_id: Some(OWNER), + }, + contract_info: DataContractInfo::OwnedDataContract(contract.clone()), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr("ask"), + override_document: false, + }), + ], + ); + + let (only_replacement, contract) = market(); + store(&only_replacement, &contract, &replacement); + assert_eq!(ask_trees(&drive), ask_trees(&only_replacement)); + } + } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index f1559c0d8c0..14c9821dfa5 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -296,6 +296,46 @@ impl DriveOperation<'_> { } } + /// Converts a member of a batch whose document TTL cleanup is complete against the operations + /// the batch built before it, and appends its own to them (protocol version 15): a document + /// operation is built with them as pending + /// ([`DocumentOperationType::append_low_level_drive_operations_after_ttl_drain`]), any other + /// as before. + pub(crate) fn append_low_level_drive_operations_after_ttl_drain( + self, + drive: &Drive, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + block_info: &BlockInfo, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match self { + Self::DocumentOperation(operation) => operation + .append_low_level_drive_operations_after_ttl_drain( + drive, + estimated_costs_only_with_layer_info, + block_info, + batch_operations, + transaction, + platform_version, + ), + operation => { + let operations = operation.into_low_level_drive_operations( + drive, + estimated_costs_only_with_layer_info, + block_info, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + Ok(()) + } + } + } + /// Convert a member of a batch whose document TTL cleanup is complete. pub(crate) fn into_low_level_drive_operations_after_ttl_drain( self, From 3dace614807087aa30ac48bfe45edfec45289b84 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 14:46:43 +0300 Subject: [PATCH 04/21] fix(drive): protocol 15 batches drain shared index groups; grouped operations see the batch - apply_drive_operations 2 builds the operations of a MultipleDocumentOperationsForSameContractDocumentType group against the enclosing batch's operations too, instead of a fresh accumulator. - batched_group_drain: the five drain specifications, #[ignore]d as a latent defect while the documents batch cap is 1, run and pass at the latest version; the phantom-group characterization applies its batch at protocol version 14 (generation 1). The module, case and SystemLimits docs say protocol version 15 removes the hazard and that the cap's other dependencies still stand. - Tests: a deletion and a grouped insert into the same bucket in one batch; the frozen-funds converter at the latest version in a group with listings (proposal records balance plus escrow, completion destroys and records the total). Co-Authored-By: Claude Opus 5.5 --- .../v0/tests/batched_group_drain.rs | 78 ++++++++++++------- .../token_destroy_frozen_funds_transition.rs | 55 +++++++++++++ .../src/util/batch/drive_op_batch/document.rs | 69 +++++++++++++++- .../apply_drive_operations/v2/mod.rs | 39 ++++++++++ .../src/version/system_limits/mod.rs | 11 ++- 5 files changed, 215 insertions(+), 37 deletions(-) diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs index 531272d06aa..45ef423b6c1 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs @@ -20,13 +20,15 @@ //! batch state transition carries at most one document transition, and two //! transitions in the same block become two separate GroveDB batches //! (`execute_event` calls `apply_drive_operations` once per state transition). -//! The cases that expose the defect are therefore `#[ignore]`d rather than -//! deleted or weakened: they are the specification of one hazard raising that -//! cap would have to fix — not of the whole shape, since a real multi-transition -//! batch would also fold several writes to the same identity-contract nonce key -//! into one batch. The cases that run pin what the system relies on today — the -//! phantom is characterized as it actually is, the inverse shape fails loud, -//! and the sequential controls drain correctly. +//! From protocol version 15, `apply_drive_operations` 2 builds every operation +//! of a batch against the batch's operations built before it, so two deletes +//! that jointly empty a group see each other and the group is removed. The cases +//! that specify the drain run at the latest version; the phantom generation 1 +//! leaves (protocol version 14 and earlier) stays characterized as it actually +//! is, the inverse shape fails loud, and the sequential controls drain +//! correctly. They specify one hazard raising the cap would have had to fix, +//! not the whole shape: a real multi-transition batch would also fold several +//! writes to the same identity-contract nonce key into one batch. //! //! The cap is not the only guard of its kind. `update_contract_keywords` builds //! its own multi-document blind batch over one shared index group and is kept @@ -214,13 +216,22 @@ fn update_op<'a>( } fn apply_batch(drive: &Drive, operations: Vec) { + apply_batch_at(drive, operations, platform_version()); +} + +/// Applies `operations` as one batch the way `platform_version` does. +fn apply_batch_at( + drive: &Drive, + operations: Vec, + platform_version: &PlatformVersion, +) { drive .apply_drive_operations( operations, true, &BlockInfo::default(), None, - platform_version(), + platform_version, None, ) .expect("the batch must apply"); @@ -570,21 +581,32 @@ fn assert_baseline(drive: &Drive, contract: &DataContract, axis: Axis) { /// storage, grovedb's integrity sweep reports nothing, and the proved page /// both equals the unproved one and reconstructs the live root hash. /// -/// It goes red the day the defect is fixed, or the day grovedb starts -/// detecting it. That is the intended signal, not a regression: delete this -/// test then, and un-ignore the cases below. +/// It applies the batch the way protocol version 14 does: generation 1 of +/// `apply_drive_operations`, which every released version runs. Generation 2 +/// (protocol version 15) builds each operation against the batch's earlier +/// ones and removes the group; the cases below specify that. #[test] fn a_batched_drain_leaves_a_phantom_group_that_verifies_and_proves() { + let generation_1 = PlatformVersion::get(14).expect("expected protocol version 14"); + assert_eq!( + generation_1 + .drive + .methods + .batch_operations + .apply_drive_operations, + 1 + ); for axis in AXES { let (drive, contract, docs) = setup_g_two_h_one(axis); assert_baseline(&drive, &contract, axis); - apply_batch( + apply_batch_at( &drive, vec![ delete_op(&contract, axis.doctype, docs[1].id()), delete_op(&contract, axis.doctype, docs[2].id()), ], + generation_1, ); let path = indexed_property_name_tree_path(&contract, axis.doctype); @@ -651,18 +673,18 @@ fn a_batched_drain_leaves_a_phantom_group_that_verifies_and_proves() { } } -/// **Known latent defect.** Both of `G`'s documents are deleted in one +/// **A latent defect before protocol version 15.** Both of `G`'s documents are deleted in one /// `apply_drive_operations` call. Neither delete can see the other, so neither /// removes `G`'s group tree: `G` stays in the ranked secondary with a zero /// aggregate and no documents behind it, on all three axes, with /// `verify_grovedb` clean and the proof reconstructing the live root hash. /// -/// Unreachable today because `max_transitions_in_documents_batch` is 1, which -/// keeps any two document operations out of a shared GroveDB batch. Raising -/// that cap arms this; see the documentation on -/// `SystemLimits::max_transitions_in_documents_batch`. +/// From protocol version 15 the second delete is built against the first and the +/// group is removed; this case runs at the latest version. Before 15 the defect +/// stands (see `a_batched_drain_leaves_a_phantom_group_that_verifies_and_proves`) +/// and is unreachable because `max_transitions_in_documents_batch` is 1; see the +/// documentation on `SystemLimits::max_transitions_in_documents_batch`. #[test] -#[ignore = "documents a latent defect in shared write-path machinery that is unreachable while max_transitions_in_documents_batch is 1; un-ignore before raising that cap"] fn deleting_a_groups_last_two_documents_in_one_batch_removes_the_group() { for axis in AXES { let (drive, contract, docs) = setup_g_two_h_one(axis); @@ -687,14 +709,13 @@ fn deleting_a_groups_last_two_documents_in_one_batch_removes_the_group() { } } -/// **Known latent defect**, same as +/// **A latent defect before protocol version 15**, same as /// [`deleting_a_groups_last_two_documents_in_one_batch_removes_the_group`], /// with the two deletes in the opposite order — the group's emptiness is /// mis-observed either way, so the defect is not an artefact of one ordering. /// -/// Unreachable while `max_transitions_in_documents_batch` is 1. +/// Removed from protocol version 15; this case runs at the latest version. #[test] -#[ignore = "documents a latent defect in shared write-path machinery that is unreachable while max_transitions_in_documents_batch is 1; un-ignore before raising that cap"] fn reverse_ordered_batched_deletes_remove_the_group_too() { for axis in AXES { let (drive, contract, docs) = setup_g_two_h_one(axis); @@ -718,14 +739,13 @@ fn reverse_ordered_batched_deletes_remove_the_group_too() { } } -/// **Known latent defect**, same as +/// **A latent defect before protocol version 15**, same as /// [`deleting_a_groups_last_two_documents_in_one_batch_removes_the_group`], on /// a Drive configured the way a shipped node is — so it cannot be dismissed as /// an artefact of the test harness's `batching_consistency_verification: true`. /// -/// Unreachable while `max_transitions_in_documents_batch` is 1. +/// Removed from protocol version 15; this case runs at the latest version. #[test] -#[ignore = "documents a latent defect in shared write-path machinery that is unreachable while max_transitions_in_documents_batch is 1; un-ignore before raising that cap"] fn batched_deletes_remove_the_group_under_the_shipped_batching_config() { for axis in AXES { let (drive, contract) = setup_restaurants_with_shipped_batching_config(); @@ -754,7 +774,7 @@ fn batched_deletes_remove_the_group_under_the_shipped_batching_config() { } } -/// **Known latent defect.** The batched drain and the identical drain applied +/// **A latent defect before protocol version 15.** The batched drain and the identical drain applied /// one delete at a time must land the same logical index; they do not. /// /// The comparison is over entries and primary aggregates rather than the root @@ -764,9 +784,8 @@ fn batched_deletes_remove_the_group_under_the_shipped_batching_config() { /// deterministic for nodes replaying identical history, so it is not itself a /// consensus hazard — but it makes the app hash the wrong instrument here. /// -/// Unreachable while `max_transitions_in_documents_batch` is 1. +/// Removed from protocol version 15; this case runs at the latest version. #[test] -#[ignore = "documents a latent defect in shared write-path machinery that is unreachable while max_transitions_in_documents_batch is 1; un-ignore before raising that cap"] fn batched_and_sequential_drains_agree_on_the_logical_state() { for axis in AXES { let (batched, batched_contract, batched_docs) = setup_g_two_h_one(axis); @@ -841,16 +860,15 @@ fn update_document_singly(drive: &Drive, contract: &DataContract, axis: Axis, do .expect("the single-document update must succeed"); } -/// **Known latent defect**, reached through `UpdateDocument` rather than +/// **A latent defect before protocol version 15**, reached through `UpdateDocument` rather than /// `DeleteDocument`: one batch moves both of `G`'s documents into `H`. `H` /// correctly gains all three contributions, but `G` survives as a zero-valued /// phantom, so the defect is not specific to deletes — any operation that /// empties a group while a sibling operation in the same batch has not been /// observed reproduces it. /// -/// Unreachable while `max_transitions_in_documents_batch` is 1. +/// Removed from protocol version 15; this case runs at the latest version. #[test] -#[ignore = "documents a latent defect in shared write-path machinery that is unreachable while max_transitions_in_documents_batch is 1; un-ignore before raising that cap"] fn moving_a_groups_last_two_documents_to_another_group_in_one_batch_drains_it() { for axis in AXES { let (drive, contract, docs) = setup_g_two_h_one(axis); diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs index fdbccb722ef..e7d80c30068 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs @@ -446,4 +446,59 @@ mod tests { )); assert!(is_history_of(&operations[3], AMOUNT + 500)); } + + /// Generation 1 in a group, with listings: a proposal that does not perform yet records + /// the group action with everything the destroy will take (the balance and the listings' + /// escrow) and destroys nothing; the signature that completes it closes the action, + /// destroys the holding, deletes the listings and records the total. + #[test] + fn should_record_the_listings_in_a_group_action_at_protocol_version_15() { + let latest = PlatformVersion::latest(); + let listings = || vec![listing(1, 300), listing(2, 200)]; + + let operations = convert(action(Some(in_group(true)), false, listings()), latest); + assert_eq!(operations.len(), 2, "{operations:?}"); + assert!(is_nonce(&operations[0])); + assert!(matches!( + &operations[1], + GroupOperation(GroupOperationType::AddGroupAction { + initialize_with_insert_action_info: Some(GroupAction::V0(GroupActionV0 { + event: GroupActionEvent::TokenEvent(TokenEvent::DestroyFrozenFunds( + frozen, + destroyed, + _ + )), + .. + })), + closes_group_action: false, + .. + }) if *frozen == Identifier::from(FROZEN) && *destroyed == AMOUNT + 500 + )); + + let operations = convert(action(Some(in_group(false)), true, listings()), latest); + assert_eq!(operations.len(), 5, "{operations:?}"); + assert!(is_nonce(&operations[0])); + assert!(matches!( + &operations[1], + GroupOperation(GroupOperationType::AddGroupAction { + initialize_with_insert_action_info: None, + closes_group_action: true, + .. + }) + )); + assert!(matches!( + &operations[2], + TokenOperation(TokenOperationType::TokenDestroyFrozenHolding { + balance_amount: AMOUNT, + listings, + .. + }) if *listings == vec![(Identifier::from([1; 32]), 300), (Identifier::from([2; 32]), 200)] + )); + assert!(matches!( + &operations[3], + DocumentOperation(DocumentOperationType::DeleteDocuments { document_ids, .. }) + if *document_ids == vec![Identifier::from([1; 32]), Identifier::from([2; 32])] + )); + assert!(is_history_of(&operations[4], AMOUNT + 500)); + } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs index a2fce605b12..1c24889af3d 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs @@ -587,9 +587,72 @@ impl DocumentOperationType<'_> { )?; batch_operations.extend(operations); } - // A contested insert, a withdrawal, a document history entry and a group of - // operations for one document type: built as before, after the batch's earlier - // operations. + // A group of operations for one document type: each built against the batch's + // operations so far, the group's earlier ones included. + DocumentOperationType::MultipleDocumentOperationsForSameContractDocumentType { + document_operations, + } => { + let DocumentOperationsForContractDocumentType { + operations, + contract, + document_type, + } = document_operations; + for document_operation in operations { + let operations = match document_operation { + DocumentOperation::AddOperation { + owned_document_info, + override_document, + } => drive.add_document_for_contract_operations_without_ttl_drain( + DocumentAndContractInfo { + owned_document_info, + contract, + document_type, + }, + override_document, + block_info, + &mut Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?, + DocumentOperation::UpdateOperation(UpdateOperationInfo { + document, + serialized_document, + owner_id, + storage_flags, + }) => { + let document_info = + if let Some(serialized_document) = serialized_document { + DocumentRefAndSerialization(( + document, + serialized_document, + storage_flags, + )) + } else { + DocumentRefInfo((document, storage_flags)) + }; + drive.update_document_for_contract_operations_without_ttl_drain( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info, + owner_id, + }, + contract, + document_type, + }, + block_info, + &mut Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )? + } + }; + batch_operations.extend(operations); + } + } + // A contested insert, a withdrawal and a document history entry: built as before, + // after the batch's earlier operations. operation => { let operations = operation.into_low_level_drive_operations_after_ttl_drain( drive, 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 index 597370f0d0f..1fa894048cb 100644 --- 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 @@ -337,6 +337,9 @@ mod tests { mod batch_document_writes { use crate::drive::{Drive, RootTree}; + use crate::util::batch::drive_op_batch::{ + DocumentOperation, DocumentOperationsForContractDocumentType, + }; use crate::util::batch::{DocumentOperationType, DriveOperation}; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{ @@ -525,6 +528,42 @@ mod tests { ); } + /// The same with the insert sent as a group of operations for one document type: a + /// group's operations are built against the batch's earlier ones too. + #[test] + fn should_insert_through_a_group_into_a_tree_an_earlier_deletion_empties() { + let (drive, contract) = market(); + store(&drive, &contract, &ask(1, 0xA1)); + let replacement = ask(2, 0xA1); + apply( + &drive, + vec![ + delete(&contract, &[1]), + DriveOperation::DocumentOperation( + DocumentOperationType::MultipleDocumentOperationsForSameContractDocumentType { + document_operations: DocumentOperationsForContractDocumentType { + operations: vec![DocumentOperation::AddOperation { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&replacement, None)), + owner_id: Some(OWNER), + }, + override_document: false, + }], + contract: &contract, + document_type: contract + .document_type_for_name("ask") + .expect("expected the ask type"), + }, + }, + ), + ], + ); + + let (only_replacement, contract) = market(); + store(&only_replacement, &contract, &replacement); + assert_eq!(ask_trees(&drive), ask_trees(&only_replacement)); + } + /// A deletion that empties a bucket and an insert into the same bucket in one batch: the /// insert cancels the removal, and the `ask` trees are those of the new ask alone. #[test] diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index a9b0bd3ee83..28d471f571d 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -89,7 +89,9 @@ pub struct SystemLimits { /// a single GroveDB batch. Within one such batch the ordinary document Add/Update/Delete /// conversions are blind to each other: the check that decides whether an index group tree /// has become empty and should be removed sees committed state plus only the operations of - /// its own conversion. + /// its own conversion. From protocol version 15, `apply_drive_operations` 2 builds every + /// operation against the batch's operations built before it, so this blindness is gone + /// there; protocol version 14 and earlier keep it. /// /// While the cap is 1, no two document operations can share a GroveDB batch *by way of a /// batch state transition*, so on that route the blindness has nothing to act on. Raise it @@ -151,9 +153,10 @@ pub struct SystemLimits { /// then start refusing batches that fee validation would have executed. Recheck the /// inequality rather than assuming it. /// - /// Five cases in `rs-drive`'s `batched_group_drain` suite are `#[ignore]`d for exactly this - /// reason; the rest of that suite runs. Anyone raising this cap should un-ignore those five - /// first and make them pass. + /// Five cases in `rs-drive`'s `batched_group_drain` suite specify the group drain this cap + /// guards against; they run at protocol version 15, whose batch application removes the + /// hazard, and the phantom group generation 1 leaves is characterized at 14. The other + /// dependencies above still stand: raising the cap needs them resolved, not just these five. pub max_transitions_in_documents_batch: u16, pub withdrawal_transactions_per_block_limit: u16, pub retry_signing_expired_withdrawal_documents_per_block_limit: u16, From 56bd477013bea6b471e348d9386a35406209bfcc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 16:49:51 +0300 Subject: [PATCH 05/21] Merge branch 'claude/token-market-history' into claude/token-market-frozen-escrow The frozen-listing helper signs the token's decimals, as listings now do. The batch-aware document lowering lists its remaining operation types explicitly instead of a wildcard, and a destroy of a partly filled listing takes what is left in escrow (new test). Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 46 +++++++++++++++++++ .../src/util/batch/drive_op_batch/document.rs | 7 ++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 4604cdb10d6..2b04f86a964 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -2707,6 +2707,7 @@ mod frozen { /// A listing by the buyer at block time `time_ms`, and its order id. async fn buyer_lists(&mut self, amount: u64, time_ms: u64) -> Identifier { let platform_version = PlatformVersion::latest(); + let decimals = self.token_decimals(); let nonce = self.buyer.take_nonce(); let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.buyer.id(), nonce); let place = BatchTransition::new_token_place_ask_transition( @@ -2716,6 +2717,7 @@ mod frozen { 0, amount, PRICE, + decimals, 1, None, &self.buyer.key, @@ -2924,6 +2926,50 @@ mod frozen { ); } + /// A listing partly sold before its maker is frozen: the destroy takes what is still in + /// escrow (`remaining`), not what was listed. 3,000 listed and 1,000 sold leave 2,000 in + /// escrow; with 5,000 in the balance the destroy lowers the supply by 7,000 and records + /// 7,000, and the seller keeps the 1,000 it bought. + #[tokio::test] + async fn should_destroy_what_is_left_of_a_partly_filled_listing() { + let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); + market.give_buyer(8_000).await; + let order_id = market.buyer_lists(3_000, 1).await; + let fill = market.seller_fills_ask(order_id, 1_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!(market.escrow(order_id), Some(2_000)); + assert_eq!(market.token_balance(market.buyer.id()), Some(5_000)); + let seller_balance = market + .token_balance(market.seller.id()) + .expect("expected the seller's balance"); + let supply = market.supply(); + market.freeze_buyer().await; + + market.destroy_buyers_frozen_funds().await; + + assert_eq!(market.token_balance(market.buyer.id()), Some(0)); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None, "the listing is deleted"); + assert_eq!(market.supply(), supply - 7_000); + assert_eq!( + market.token_balance(market.seller.id()), + Some(seller_balance), + "the seller keeps what it bought" + ); + market.assert_supply_balanced(); + + let destroyed = market.history("destroyFrozenFunds"); + assert_eq!(destroyed.len(), 1); + assert_eq!( + destroyed[0] + .properties() + .get("destroyedAmount") + .and_then(|amount| amount.to_integer::().ok()), + Some(7_000), + "the record counts the balance and what is left in escrow" + ); + } + #[tokio::test] async fn should_destroy_a_frozen_identitys_listings_with_its_balance() { let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs index 1c24889af3d..cd9f8bcefbf 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs @@ -652,8 +652,11 @@ impl DocumentOperationType<'_> { } } // A contested insert, a withdrawal and a document history entry: built as before, - // after the batch's earlier operations. - operation => { + // after the batch's earlier operations. Listed rather than matched with a wildcard, + // so a new operation type has to decide whether it is built against them. + operation @ (DocumentOperationType::AddContestedDocument { .. } + | DocumentOperationType::AddWithdrawalDocument { .. } + | DocumentOperationType::DocumentHistory { .. }) => { let operations = operation.into_low_level_drive_operations_after_ttl_drain( drive, estimated_costs_only_with_layer_info, From c8c9b65498db7891f984ce45b1139917835d11c2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 22 Oct 2026 23:31:51 +0300 Subject: [PATCH 06/21] fix(drive): the raw batch converter builds each operation against the batch's earlier ones at protocol 15 convert_drive_operations_to_grove_operations 2, selected by DRIVE_VERSION_V10, is generation 1 (repeated counter moves refused, TTL preparation) with every operation appended through append_low_level_drive_operations_after_ttl_drain, as apply_drive_operations 2 does, so two deletions emptying a shared index tree remove it on this entry point too. Credits that repaid a debt and ephemeral operations are refused as before. New test: one owner's asks of two tokens deleted through the converter leave the trees deleting them separately leaves. Co-Authored-By: Claude Opus 5.5 --- .../apply_drive_operations/v2/mod.rs | 41 +++++++++++ .../mod.rs | 9 ++- .../v2/mod.rs | 73 +++++++++++++++++++ .../src/version/drive_versions/v10.rs | 2 +- 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs 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 index 1fa894048cb..6f5c5b339ae 100644 --- 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 @@ -564,6 +564,47 @@ mod tests { assert_eq!(ask_trees(&drive), ask_trees(&only_replacement)); } + /// The same two deletions through the converter that returns a batch instead of applying + /// it (`convert_drive_operations_to_grove_operations` 2, protocol version 15): applied, + /// its batch leaves the trees deleting the two in two batches leaves. + #[test] + fn should_remove_a_shared_tree_through_the_raw_converter() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = market(); + store(&drive, &contract, &ask(1, 0xA1)); + store(&drive, &contract, &ask(2, 0xA2)); + let transaction = drive.grove.start_transaction(); + let batch = drive + .convert_drive_operations_to_grove_operations( + vec![delete(&contract, &[1]), delete(&contract, &[2])], + &BlockInfo::default(), + Some(&transaction), + platform_version, + ) + .expect("expected the conversion"); + drive + .grove_apply_batch(batch, false, Some(&transaction), &platform_version.drive) + .expect("expected the batch to apply"); + drive + .commit_transaction(transaction, &platform_version.drive) + .expect("expected to commit"); + + let (separately, contract) = market(); + store(&separately, &contract, &ask(1, 0xA1)); + store(&separately, &contract, &ask(2, 0xA2)); + apply(&separately, vec![delete(&contract, &[1])]); + apply(&separately, vec![delete(&contract, &[2])]); + + let left = ask_trees(&drive); + assert_eq!(left, ask_trees(&separately)); + assert!( + !left + .iter() + .any(|path| path.iter().any(|key| key.as_slice() == OWNER.as_slice())), + "no tree keyed by the owner stays" + ); + } + /// A deletion that empties a bucket and an insert into the same bucket in one batch: the /// insert cancels the removal, and the `ask` trees are those of the new ask alone. #[test] diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/mod.rs index 8835e4cd5d1..186de63c687 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; @@ -55,9 +56,15 @@ impl Drive { transaction, platform_version, ), + 2 => self.convert_drive_operations_to_grove_operations_v2( + drive_batch_operations, + block_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "convert_drive_operations_to_grove_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/convert_drive_operations_to_grove_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs new file mode 100644 index 00000000000..628094b05e3 --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs @@ -0,0 +1,73 @@ +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; +use crate::util::batch::{DriveOperation, GroveDbOpBatch}; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Version 2 of [`Drive::convert_drive_operations_to_grove_operations`] (protocol version + /// 15): version 1, with every operation built against the operations of the batch built + /// before it, as `apply_drive_operations` 2 builds them + /// ([`DriveOperation::append_low_level_drive_operations_after_ttl_drain`]). Two deletions + /// that empty an index tree together remove it, and an insert into a tree an earlier deletion + /// of the batch empties keeps the tree. Credits that repaid an identity's debt and ephemeral + /// operations are refused as by version 0, for the same reasons. + #[inline(always)] + pub(super) fn convert_drive_operations_to_grove_operations_v2( + &self, + drive_batch_operations: Vec, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + self.refuse_repeated_counter_moves( + &drive_batch_operations, + block_info, + transaction, + platform_version, + )?; + self.prepare_drive_operations_time_range_ttl( + &drive_batch_operations, + block_info, + transaction, + platform_version, + )?; + let mut batch_operations: Vec = vec![]; + for drive_op in drive_batch_operations { + drive_op.append_low_level_drive_operations_after_ttl_drain( + self, + &mut None, + block_info, + &mut batch_operations, + transaction, + platform_version, + )?; + } + if LowLevelDriveOperation::holds_repaid_identity_debt(&batch_operations) { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "convert_drive_operations_to_grove_operations cannot carry credits that repaid an \ + identity's debt; apply them through apply_drive_operations", + ))); + } + if batch_operations.iter().any(|operation| { + matches!( + operation, + LowLevelDriveOperation::EphemeralGroveOperation(..) + ) + }) { + return Err(Error::Drive(DriveError::NotSupported( + "convert_drive_operations_to_grove_operations returns one plain batch and cannot \ + carry ephemeral operations (a TTL'd subtree's, or a document's with a time to \ + live), whose bytes are priced separately; apply them through \ + apply_drive_operations", + ))); + } + Ok(GroveDbOpBatch::from_operations( + LowLevelDriveOperation::grovedb_operations_consume(batch_operations), + )) + } +} diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 6af738c75c2..488443c7240 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -65,8 +65,8 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { ..DRIVE_IDENTITY_METHOD_VERSIONS_V2 }, batch_operations: DriveBatchOperationsMethodVersion { + convert_drive_operations_to_grove_operations: 2, // changed in v10: each operation is built against the batch's earlier ones apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions keeping the deeper - ..DRIVE_VERSION_V9.methods.batch_operations }, prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { estimated_cost_for_prefunded_specialized_balance_update: 2, // changed in v10: keeps a deeper prefunded balances layer description From aaddedc7081f20f086536de0b0e153a8127e1b92 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 04:53:23 +0300 Subject: [PATCH 07/21] fix(drive): the shared token history builder records a destroy's listings TokenTransitionAction::associated_token_event, and build_historical_document through it, recorded only the frozen balance of a DestroyFrozenFunds, while the converter from protocol version 15 records the balance and the listings' escrow. Both now read TokenDestroyFrozenFundsTransitionAction::destroyed_amount, selected by the converter's own version: the balance alone in generation 0, the checked total from generation 1. associated_token_event takes the platform version and returns a Result for it. Co-Authored-By: Claude Opus 5.5 --- .../token_destroy_frozen_funds_transition.rs | 59 +++++++++++++++++-- .../batch/token/token_transition.rs | 21 ++++--- .../token_transition/mod.rs | 2 +- .../mod.rs | 36 +++++++++++ 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs index e7d80c30068..f2d144ed08f 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs @@ -101,7 +101,7 @@ impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransition } // From protocol version 15: also destroys what the frozen identity's open listings // hold in escrow, and deletes their `ask` documents. - 1 => self.destroy_frozen_funds_and_listings_operations_v1(owner_id), + 1 => self.destroy_frozen_funds_and_listings_operations_v1(owner_id, platform_version), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "TokenDestroyFrozenFundsTransitionAction::into_high_level_document_drive_operations" .to_string(), @@ -123,6 +123,7 @@ impl TokenDestroyFrozenFundsTransitionAction { fn destroy_frozen_funds_and_listings_operations_v1<'b>( self, owner_id: Identifier, + platform_version: &PlatformVersion, ) -> Result>, Error> { let data_contract_id = self.base().data_contract_id(); @@ -134,11 +135,7 @@ impl TokenDestroyFrozenFundsTransitionAction { .iter() .map(|listing| (listing.id, listing.remaining)) .collect(); - let destroyed_amount = - self.total_destroyed() - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "a frozen holding is more tokens than a u64 holds", - )))?; + let destroyed_amount = self.destroyed_amount(platform_version)?; let mut ops = vec![IdentityOperation( IdentityOperationType::UpdateIdentityContractNonce { @@ -239,6 +236,8 @@ mod tests { TokenBaseTransitionAction, TokenBaseTransitionActionV0, }; use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionActionV0; + use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; + use dpp::block::block_info::BlockInfo; use dpp::data_contract::group::v0::GroupV0; use dpp::data_contract::group::Group; use dpp::tokens::token_order::TokenOrder; @@ -501,4 +500,52 @@ mod tests { )); assert!(is_history_of(&operations[4], AMOUNT + 500)); } + + /// The history the converter emits is the history the shared builders reconstruct + /// (`associated_token_event`, `build_historical_document`): the frozen balance alone at + /// protocol version 14, also when the action carries listings, and the balance with the + /// listings' escrow at 15. + #[test] + fn should_emit_the_history_the_shared_builders_reconstruct() { + let block_info = BlockInfo::default(); + for (platform_version, destroyed) in + [(pv14(), AMOUNT), (PlatformVersion::latest(), AMOUNT + 500)] + { + let action = action(None, true, vec![listing(1, 300), listing(2, 200)]); + let token_action = TokenTransitionAction::DestroyFrozenFundsAction(action.clone()); + let operations = convert(action, platform_version); + let Some(TokenOperation(TokenOperationType::TokenHistory { event, .. })) = + operations.last() + else { + panic!("expected the history last: {operations:?}"); + }; + assert!(is_history_of(operations.last().unwrap(), destroyed)); + + let reconstructed = token_action + .associated_token_event(platform_version) + .expect("expected the event"); + assert_eq!(&reconstructed, event); + + let emitted_document = event + .clone() + .build_historical_document_owned( + Identifier::from(TOKEN_ID), + Identifier::from(SIGNER), + NONCE, + &block_info, + platform_version, + ) + .expect("expected the emitted history document"); + let rebuilt_document = token_action + .build_historical_document( + Identifier::from(TOKEN_ID), + Identifier::from(SIGNER), + NONCE, + &block_info, + platform_version, + ) + .expect("expected the rebuilt history document"); + assert_eq!(rebuilt_document, emitted_document); + } + } } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs index f2e14c475e1..a713f44474c 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs @@ -47,7 +47,9 @@ impl DriveHighLevelBatchOperationConverter for TokenTransitionAction { | TokenTransitionAction::FillAskAction(_) | TokenTransitionAction::CancelOrderAction(_) | TokenTransitionAction::PlaceBidAction(_) - | TokenTransitionAction::FillBidAction(_) => self.token_history_operation(owner_id)?, + | TokenTransitionAction::FillBidAction(_) => { + self.token_history_operation(owner_id, platform_version)? + } _ => None, }; match self { @@ -145,6 +147,7 @@ impl TokenTransitionAction { fn token_history_operation<'b>( &self, owner_id: Identifier, + platform_version: &PlatformVersion, ) -> Result>, Error> { if !self.keeps_history()? { return Ok(None); @@ -154,14 +157,18 @@ impl TokenTransitionAction { token_id: self.base().token_id(), owner_id, nonce: self.base().identity_contract_nonce(), - event: self.associated_token_event(), + event: self.associated_token_event(platform_version)?, }, ))) } - /// Gets the associated token event for the transition action - pub fn associated_token_event(&self) -> TokenEvent { - match self { + /// Gets the associated token event for the transition action: the event its conversion at + /// `platform_version` records in the token history + pub fn associated_token_event( + &self, + platform_version: &PlatformVersion, + ) -> Result { + Ok(match self { TokenTransitionAction::BurnAction(burn_action) => TokenEvent::Burn( burn_action.burn_amount(), burn_action.burn_from_identifier(), @@ -205,7 +212,7 @@ impl TokenTransitionAction { TokenTransitionAction::DestroyFrozenFundsAction(destroy_action) => { TokenEvent::DestroyFrozenFunds( destroy_action.frozen_identity_id(), - destroy_action.amount(), + destroy_action.destroyed_amount(platform_version)?, destroy_action.public_note().cloned(), ) } @@ -285,6 +292,6 @@ impl TokenTransitionAction { action.credits(), action.order().decimals, ), - } + }) } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs index 16339ad85f6..ed431bc3f2a 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs @@ -281,7 +281,7 @@ impl TokenTransitionAction { block_info: &BlockInfo, platform_version: &PlatformVersion, ) -> Result { - self.associated_token_event() + self.associated_token_event(platform_version)? .build_historical_document_owned( token_id, owner_id, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs index e46f5f69dbc..a8f531f069a 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs @@ -11,7 +11,10 @@ mod v0; pub use v0::*; // re-export the v0 module items (including TokenIssuanceTransitionActionV0) +use crate::error::drive::DriveError; +use crate::error::Error; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; +use platform_version::version::PlatformVersion; /// Token destroy_frozen_funds transition action #[derive(Debug, Clone, From)] @@ -90,6 +93,39 @@ impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 } } +impl TokenDestroyFrozenFundsTransitionAction { + /// What the transition's history and group-action events record as destroyed, as the + /// converter generation `platform_version` selects + /// (`token_destroy_frozen_funds_transition`): the frozen balance alone in generation 0, and + /// the balance with what the listings hold in escrow from generation 1 (protocol version + /// 15). The converter and the shared builders (`associated_token_event`, + /// `build_historical_document`) all read it, so they record the same amount. + pub fn destroyed_amount( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_destroy_frozen_funds_transition + { + 0 => Ok(self.amount()), + 1 => self + .total_destroyed() + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a frozen holding is more tokens than a u64 holds", + ))), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenDestroyFrozenFundsTransitionAction::destroyed_amount".to_string(), + known_versions: vec![0, 1], + received: version, + })), + } + } +} + #[cfg(test)] mod tests { use super::*; From ea6abc731fcac47dad9c677df5d7968df50d221a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 05:28:18 +0300 Subject: [PATCH 08/21] test(drive-abci): the shared-index destroy regression requires a positive cancel refund Co-Authored-By: Claude Opus 5.5 --- .../state_transitions/batch/tests/token/marketplace/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index ee6cfe3de5c..1c67f1fb5b5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -3452,6 +3452,10 @@ mod frozen { trees_left.len(), trees_with_listings.len() ); + assert!( + cancel_refunds > 0, + "the cancellation control must refund the listing owner's storage" + ); assert!( destroy_refund >= cancel_refunds, "the destroy refunds {destroy_refund}, the cancels {cancel_refunds}" From 4fb947d9c1da30df820bf78f5effbb2bfb5b17a7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 06:00:07 +0300 Subject: [PATCH 09/21] test(drive-abci): a frozen identity's offer keeps its stored credit escrow The offer-preservation test also reads the prefunded escrow entry, not only the bid document's escrow field. Co-Authored-By: Claude Opus 5.5 --- .../state_transitions/batch/tests/token/marketplace/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 1c67f1fb5b5..ab2244909a3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -1934,7 +1934,7 @@ mod offers { self.executes(&freeze); } - fn credit_escrow(&self, order_id: Identifier) -> Option { + pub(super) fn credit_escrow(&self, order_id: Identifier) -> Option { self.platform .drive .fetch_credit_order_escrow( @@ -3586,5 +3586,10 @@ mod frozen { Some(10_000), "the offer still holds its escrow" ); + assert_eq!( + market.credit_escrow(order_id), + Some(10_000), + "the offer's escrowed credits survive the token destroy" + ); } } From c9db21ed226c2c26b53eaad418417b1415ff4969 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 23 Oct 2026 15:07:21 +0300 Subject: [PATCH 10/21] test(drive-abci): a frozen destroy leaves other holders' and other tokens' listings A same-token listing by another holder and the frozen identity's own listing of another token keep their documents and escrow through the destroy, and the other token's supply does not move. The order fixture and listing calls follow orders losing their expiry. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 115 +++++++++++++++++- .../token_destroy_frozen_funds_transition.rs | 1 - 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 67c96a0c727..cfd0afec646 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -3429,7 +3429,6 @@ mod frozen { PRICE, decimals, 1, - None, &self.buyer.key, nonce, 0, @@ -3715,6 +3714,118 @@ mod frozen { ); } + /// The destroy reads only the frozen identity's listings of the destroyed token: a listing + /// of that token by another holder, and the frozen identity's own listing of another token, + /// keep their documents and their escrow, and the other token's supply does not move. + #[tokio::test] + async fn should_leave_other_owners_listings_and_other_tokens_listings() { + let platform_version = PlatformVersion::latest(); + let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); + market.give_buyer(10_000).await; + let frozen_listing = market.buyer_lists(3_000, 1).await; + + // Another holder's listing of the same token. + let (place, sellers_listing) = market.place_ask(500, PRICE, 1).await; + market.assert_executes(&place, &BlockInfo::default()); + + // The frozen identity's listing of another token, of a contract it owns. + let (other_contract, other_token) = create_token_contract_with_owner_identity( + &mut market.platform, + market.buyer.id(), + Some(make_token_tradeable_and_freezable), + None, + None, + None, + platform_version, + ); + let other_nonce = 1; + let other_listing = + TokenOrderSide::Ask.order_id(other_token, market.buyer.id(), other_nonce); + let place = BatchTransition::new_token_place_ask_transition( + other_token, + market.buyer.id(), + other_contract.id(), + 0, + 400, + PRICE, + market.token_decimals(), + 1, + &market.buyer.key, + other_nonce, + 0, + &market.buyer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the listing of the other token"); + market.assert_executes(&place, &BlockInfo::default()); + let other_escrow = |market: &Market| { + market + .platform + .drive + .fetch_token_order_escrow( + other_token.to_buffer(), + other_listing.to_buffer(), + None, + &mut vec![], + platform_version, + ) + .expect("expected to fetch the escrow") + }; + let other_supply = |market: &Market| { + market + .platform + .drive + .fetch_token_total_supply(other_token.to_buffer(), None, platform_version) + .expect("expected to fetch the supply") + }; + let other_supply_before = other_supply(&market); + assert_eq!(other_escrow(&market), Some(400)); + + market.freeze_buyer().await; + market.destroy_buyers_frozen_funds().await; + + assert_eq!( + market.listing(frozen_listing), + None, + "the frozen listing is destroyed" + ); + assert_eq!(market.escrow(frozen_listing), None); + assert_eq!(market.supply(), BASE_SUPPLY - 10_000); + + assert!( + market.listing(sellers_listing).is_some(), + "another holder's listing of the token stays" + ); + assert_eq!(market.escrow(sellers_listing), Some(500)); + + let token_market_contract = market + .platform + .drive + .token_market_contract_fetch_info(platform_version) + .expect("expected the token market contract"); + let (document, _) = market + .platform + .drive + .fetch_token_order_document( + &token_market_contract.contract, + TokenOrderSide::Ask, + other_listing, + &BlockInfo::default().epoch, + None, + platform_version, + ) + .expect("expected to fetch the listing of the other token"); + assert!( + document.is_some(), + "the frozen identity's listing of another token stays" + ); + assert_eq!(other_escrow(&market), Some(400)); + assert_eq!(other_supply(&market), other_supply_before); + market.assert_supply_balanced(); + } + #[tokio::test] async fn should_destroy_the_oldest_listings_up_to_the_limit_per_transition() { let limit = PlatformVersion::latest() @@ -3752,7 +3863,7 @@ mod frozen { async fn should_leave_a_frozen_identitys_offers() { let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); market.give_buyer(100).await; - let (place, order_id) = market.place_bid(1_000, PRICE, 1, None).await; + let (place, order_id) = market.place_bid(1_000, PRICE, 1).await; market.assert_executes(&place, &BlockInfo::default()); market.freeze_buyer().await; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs index f2d144ed08f..04abb00836a 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs @@ -279,7 +279,6 @@ mod tests { amount: remaining, remaining, min_fill: 1, - expires_at: None, escrow: 0, decimals: 0, } From 76c7c8bc0d5253bcc51c4ce7dfc59ee801598b0c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 24 Oct 2026 08:59:49 +0300 Subject: [PATCH 11/21] fix(drive): contested and withdrawal inserts see the batch's earlier operations; generation modules From protocol version 15, apply_drive_operations 2 builds a contested document insert and a withdrawal document insert against the batch's earlier operations, as it already did for other document writes; a document history entry stays standalone, with the reason recorded at its arm. The destroy's listing read adds its cost with checked fee arithmetic. The generation-1 destroy converter and the frozen-listing fetch move into their own version modules. Co-Authored-By: Claude Opus 5.5 --- .../fetch_frozen_token_listings/mod.rs | 77 ++++++++++ .../fetch_frozen_token_listings/v0/mod.rs | 89 +++++++++++ .../drive/tokens/order_escrow/fetch_order.rs | 142 ------------------ .../src/drive/tokens/order_escrow/mod.rs | 1 + .../mod.rs} | 129 +--------------- .../v1/mod.rs | 133 ++++++++++++++++ .../v0/transformer.rs | 2 +- .../src/util/batch/drive_op_batch/document.rs | 71 ++++++++- 8 files changed, 374 insertions(+), 270 deletions(-) create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs rename packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/{token_destroy_frozen_funds_transition.rs => token_destroy_frozen_funds_transition/mod.rs} (77%) create mode 100644 packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v1/mod.rs diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs new file mode 100644 index 00000000000..e9138a21359 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs @@ -0,0 +1,77 @@ +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +use dpp::tokens::token_order::TokenOrder; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +mod v0; + +impl Drive { + /// The open listings of `owner_id` for `token_id` that a `TokenDestroyFrozenFunds` destroys + /// with the frozen balance: the oldest first, at most + /// `max_frozen_listings_destroyed_per_transition`, read through the `ask` type's `byOwner` + /// index, with the processing cost of the read billed at `epoch`. None where + /// `fetch_frozen_listings` is `None`, which reads nothing: there the frozen balance alone is + /// destroyed, as before the marketplace. + pub fn fetch_frozen_token_listings( + &self, + token_id: Identifier, + owner_id: Identifier, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Vec, u64), Error> { + match platform_version + .drive + .methods + .token + .order_escrow + .fetch_frozen_listings + { + None => Ok((vec![], 0)), + Some(0) => self.fetch_frozen_token_listings_v0( + token_id, + owner_id, + epoch, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_frozen_token_listings".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::epoch::Epoch; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + + /// Before protocol version 15 a destroy of frozen funds reads no listings: on a chain with no + /// marketplace state (no escrow trees, no token market contract) the read returns none and + /// costs nothing. + #[test] + fn should_read_no_frozen_listings_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let (listings, cost) = drive + .fetch_frozen_token_listings( + Identifier::from([5; 32]), + Identifier::from([6; 32]), + &Epoch::new(0).expect("expected an epoch"), + None, + platform_version, + ) + .expect("expected the read to be skipped"); + assert!(listings.is_empty()); + assert_eq!(cost, 0); + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs new file mode 100644 index 00000000000..f802a721519 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs @@ -0,0 +1,89 @@ +use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; +use crate::drive::Drive; +use crate::error::Error; +use crate::query::ordering::OrderClause; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::identifier::Identifier; +use dpp::platform_value::Value; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; +use indexmap::IndexMap; +use std::collections::BTreeMap; + +impl Drive { + pub(super) fn fetch_frozen_token_listings_v0( + &self, + token_id: Identifier, + owner_id: Identifier, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Vec, u64), Error> { + let token_market_contract = self + .cache + .system_data_contracts + .load_token_market(platform_version)?; + let document_type = token_market_contract + .document_type_for_name(TokenOrderSide::Ask.document_type_name())?; + let equal = |field: &str, value: Identifier| { + ( + field.to_string(), + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(value.to_buffer()), + }, + ) + }; + let query = DriveDocumentQuery { + contract: &token_market_contract, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses: BTreeMap::from([ + equal("$ownerId", owner_id), + equal("tokenId", token_id), + ]), + }, + offset: None, + limit: Some( + platform_version + .system_limits + .max_frozen_listings_destroyed_per_transition, + ), + order_by: IndexMap::from([( + "$createdAt".to_string(), + OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }, + )]), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + let outcome = self.query_documents( + query, + Some(epoch), + false, + transaction, + Some(platform_version.protocol_version), + )?; + let cost = outcome.cost(); + let listings = outcome + .documents_owned() + .iter() + .map(|document| TokenOrder::from_document(document, TokenOrderSide::Ask)) + .collect::, _>>()?; + Ok((listings, cost)) + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs index 93e834ac716..ab4a967138a 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs @@ -1,9 +1,7 @@ use crate::drive::contract::DataContractFetchInfo; use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; use crate::drive::Drive; -use crate::error::drive::DriveError; use crate::error::Error; -use crate::query::ordering::OrderClause; use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; use dpp::block::epoch::Epoch; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -11,13 +9,10 @@ use dpp::data_contract::DataContract; use dpp::document::Document; use dpp::identifier::Identifier; use dpp::platform_value::Value; -use dpp::tokens::token_order::TokenOrder; use dpp::tokens::token_order_side::TokenOrderSide; use dpp::version::PlatformVersion; use grovedb::TransactionArg; use grovedb_costs::OperationCost; -use indexmap::IndexMap; -use std::collections::BTreeMap; use std::sync::Arc; impl Drive { @@ -87,141 +82,4 @@ impl Drive { let cost = outcome.cost(); Ok((outcome.documents_owned().into_iter().next(), cost)) } - - /// The open listings of `owner_id` for `token_id` that a `TokenDestroyFrozenFunds` destroys - /// with the frozen balance: the oldest first, at most - /// `max_frozen_listings_destroyed_per_transition`, read through the `ask` type's `byOwner` - /// index, with the processing cost of the read billed at `epoch`. None where - /// `fetch_frozen_listings` is `None`, which reads nothing: there the frozen balance alone is - /// destroyed, as before the marketplace. - pub fn fetch_frozen_token_listings( - &self, - token_id: Identifier, - owner_id: Identifier, - epoch: &Epoch, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result<(Vec, u64), Error> { - match platform_version - .drive - .methods - .token - .order_escrow - .fetch_frozen_listings - { - None => Ok((vec![], 0)), - Some(0) => self.fetch_frozen_token_listings_v0( - token_id, - owner_id, - epoch, - transaction, - platform_version, - ), - Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "fetch_frozen_token_listings".to_string(), - known_versions: vec![0], - received: version, - })), - } - } - - fn fetch_frozen_token_listings_v0( - &self, - token_id: Identifier, - owner_id: Identifier, - epoch: &Epoch, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result<(Vec, u64), Error> { - let token_market_contract = self - .cache - .system_data_contracts - .load_token_market(platform_version)?; - let document_type = token_market_contract - .document_type_for_name(TokenOrderSide::Ask.document_type_name())?; - let equal = |field: &str, value: Identifier| { - ( - field.to_string(), - WhereClause { - field: field.to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(value.to_buffer()), - }, - ) - }; - let query = DriveDocumentQuery { - contract: &token_market_contract, - document_type, - internal_clauses: InternalClauses { - primary_key_in_clause: None, - primary_key_equal_clause: None, - in_clauses: Vec::new(), - range_clause: None, - equal_clauses: BTreeMap::from([ - equal("$ownerId", owner_id), - equal("tokenId", token_id), - ]), - }, - offset: None, - limit: Some( - platform_version - .system_limits - .max_frozen_listings_destroyed_per_transition, - ), - order_by: IndexMap::from([( - "$createdAt".to_string(), - OrderClause { - field: "$createdAt".to_string(), - ascending: true, - }, - )]), - start_at: None, - start_at_included: false, - block_time_ms: None, - resolved_time_ranges: vec![], - sub_queries: vec![], - }; - let outcome = self.query_documents( - query, - Some(epoch), - false, - transaction, - Some(platform_version.protocol_version), - )?; - let cost = outcome.cost(); - let listings = outcome - .documents_owned() - .iter() - .map(|document| TokenOrder::from_document(document, TokenOrderSide::Ask)) - .collect::, _>>()?; - Ok((listings, cost)) - } -} - -#[cfg(test)] -mod tests { - use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; - use dpp::block::epoch::Epoch; - use dpp::identifier::Identifier; - use dpp::version::PlatformVersion; - - /// Before protocol version 15 a destroy of frozen funds reads no listings: on a chain with no - /// marketplace state (no escrow trees, no token market contract) the read returns none and - /// costs nothing. - #[test] - fn should_read_no_frozen_listings_before_protocol_version_15() { - let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); - let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let (listings, cost) = drive - .fetch_frozen_token_listings( - Identifier::from([5; 32]), - Identifier::from([6; 32]), - &Epoch::new(0).expect("expected an epoch"), - None, - platform_version, - ) - .expect("expected the read to be skipped"); - assert!(listings.is_empty()); - assert_eq!(cost, 0); - } } diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index 835daecfd84..0208d3675d6 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -7,6 +7,7 @@ mod credits; mod estimated_costs; +mod fetch_frozen_token_listings; mod fetch_order; mod insert_trees; mod tokens; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs similarity index 77% rename from packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs rename to packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs index 04abb00836a..4b43d9c8002 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs @@ -3,20 +3,19 @@ use dpp::group::action_event::GroupActionEvent; use dpp::group::group_action::GroupAction; use dpp::group::group_action::v0::GroupActionV0; use dpp::group::GroupStateTransitionResolvedInfo; -use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; use dpp::tokens::token_event::TokenEvent; -use dpp::tokens::token_order_side::TokenOrderSide; use platform_version::version::PlatformVersion; use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; -use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::batch::{DriveOperation, IdentityOperationType}; use crate::util::batch::drive_op_batch::{GroupOperationType, TokenOperationType}; -use crate::util::batch::DriveOperation::{DocumentOperation, GroupOperation, IdentityOperation, TokenOperation}; -use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; +use crate::util::batch::DriveOperation::{GroupOperation, IdentityOperation, TokenOperation}; + +mod v1; impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransitionAction { fn into_high_level_batch_drive_operations<'b>( @@ -112,125 +111,13 @@ impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransition } } -impl TokenDestroyFrozenFundsTransitionAction { - /// Generation 1 (protocol version 15): generation 0's operations, and with them the - /// destruction of the frozen identity's open listings the transformer read. One - /// `TokenDestroyFrozenHolding` burns the balance, empties each listing's escrow and lowers - /// the supply by everything destroyed; one `DeleteDocuments` deletes the listings' `ask` - /// documents, each built against the ones before it so index trees they share are removed - /// and refunded; the history and group-action events record everything destroyed. Without - /// listings the operations are generation 0's. - fn destroy_frozen_funds_and_listings_operations_v1<'b>( - self, - owner_id: Identifier, - platform_version: &PlatformVersion, - ) -> Result>, Error> { - let data_contract_id = self.base().data_contract_id(); - - let identity_contract_nonce = self.base().identity_contract_nonce(); - - // What the destroy needs of each listing: its id and the tokens it holds. - let listings: Vec<(Identifier, TokenAmount)> = self - .escrowed_listings() - .iter() - .map(|listing| (listing.id, listing.remaining)) - .collect(); - let destroyed_amount = self.destroyed_amount(platform_version)?; - - let mut ops = vec![IdentityOperation( - IdentityOperationType::UpdateIdentityContractNonce { - identity_id: owner_id.into_buffer(), - contract_id: data_contract_id.into_buffer(), - nonce: identity_contract_nonce, - }, - )]; - - if let Some(GroupStateTransitionResolvedInfo { - group_contract_position, - action_id, - action_is_proposer, - signer_power, - .. - }) = self.base().store_in_group() - { - let event = TokenEvent::DestroyFrozenFunds( - self.frozen_identity_id(), - destroyed_amount, - self.public_note().cloned(), - ); - - let initialize_with_insert_action_info = if *action_is_proposer { - Some(GroupAction::V0(GroupActionV0 { - contract_id: self.base().data_contract_id(), - proposer_id: owner_id, - token_contract_position: self.base().token_position(), - event: GroupActionEvent::TokenEvent(event), - })) - } else { - None - }; - - ops.push(GroupOperation(GroupOperationType::AddGroupAction { - contract_id: data_contract_id, - group_contract_position: *group_contract_position, - initialize_with_insert_action_info, - action_id: *action_id, - signer_identity_id: owner_id, - signer_power: *signer_power, - closes_group_action: self.base().perform_action(), - })); - } - - if self.base().perform_action() { - if listings.is_empty() { - ops.push(TokenOperation(TokenOperationType::TokenBurn { - token_id: self.token_id(), - identity_balance_holder_id: self.frozen_identity_id(), - burn_amount: self.amount(), - })); - } else { - let token_market_contract = self.token_market_contract().cloned().ok_or( - Error::Drive(DriveError::CorruptedCodeExecution( - "listings to destroy come with the token market contract", - )), - )?; - let document_ids = listings.iter().map(|(id, _)| *id).collect(); - ops.push(TokenOperation( - TokenOperationType::TokenDestroyFrozenHolding { - token_id: self.token_id(), - holder_id: self.frozen_identity_id(), - balance_amount: self.amount(), - listings, - }, - )); - ops.push(DocumentOperation(DocumentOperationType::DeleteDocuments { - document_ids, - contract_info: DataContractInfo::DataContractFetchInfo(token_market_contract), - document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( - TokenOrderSide::Ask.document_type_name(), - ), - })); - } - - ops.push(TokenOperation(TokenOperationType::TokenHistory { - token_id: self.token_id(), - owner_id, - nonce: identity_contract_nonce, - event: TokenEvent::DestroyFrozenFunds( - self.frozen_identity_id(), - destroyed_amount, - self.public_note_owned(), - ), - })); - } - - Ok(ops) - } -} - #[cfg(test)] mod tests { use super::*; + use crate::util::batch::DocumentOperationType; + use crate::util::batch::DriveOperation::DocumentOperation; + use dpp::balances::credits::TokenAmount; + use dpp::tokens::token_order_side::TokenOrderSide; use crate::drive::contract::DataContractFetchInfo; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{ TokenBaseTransitionAction, TokenBaseTransitionActionV0, diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v1/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v1/mod.rs new file mode 100644 index 00000000000..bf81650be94 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v1/mod.rs @@ -0,0 +1,133 @@ +use dpp::group::action_event::GroupActionEvent; +use dpp::group::group_action::GroupAction; +use dpp::group::group_action::v0::GroupActionV0; +use dpp::group::GroupStateTransitionResolvedInfo; +use dpp::balances::credits::TokenAmount; +use dpp::identifier::Identifier; +use dpp::tokens::token_event::TokenEvent; +use dpp::tokens::token_order_side::TokenOrderSide; +use platform_version::version::PlatformVersion; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; +use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::batch::drive_op_batch::{GroupOperationType, TokenOperationType}; +use crate::util::batch::DriveOperation::{DocumentOperation, GroupOperation, IdentityOperation, TokenOperation}; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; + +impl TokenDestroyFrozenFundsTransitionAction { + /// Generation 1 (protocol version 15): generation 0's operations, and with them the + /// destruction of the frozen identity's open listings the transformer read. One + /// `TokenDestroyFrozenHolding` burns the balance, empties each listing's escrow and lowers + /// the supply by everything destroyed; one `DeleteDocuments` deletes the listings' `ask` + /// documents, each built against the ones before it so index trees they share are removed + /// and refunded; the history and group-action events record everything destroyed. Without + /// listings the operations are generation 0's. + pub(super) fn destroy_frozen_funds_and_listings_operations_v1<'b>( + self, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + let data_contract_id = self.base().data_contract_id(); + + let identity_contract_nonce = self.base().identity_contract_nonce(); + + // What the destroy needs of each listing: its id and the tokens it holds. + let listings: Vec<(Identifier, TokenAmount)> = self + .escrowed_listings() + .iter() + .map(|listing| (listing.id, listing.remaining)) + .collect(); + let destroyed_amount = self.destroyed_amount(platform_version)?; + + let mut ops = vec![IdentityOperation( + IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }, + )]; + + if let Some(GroupStateTransitionResolvedInfo { + group_contract_position, + action_id, + action_is_proposer, + signer_power, + .. + }) = self.base().store_in_group() + { + let event = TokenEvent::DestroyFrozenFunds( + self.frozen_identity_id(), + destroyed_amount, + self.public_note().cloned(), + ); + + let initialize_with_insert_action_info = if *action_is_proposer { + Some(GroupAction::V0(GroupActionV0 { + contract_id: self.base().data_contract_id(), + proposer_id: owner_id, + token_contract_position: self.base().token_position(), + event: GroupActionEvent::TokenEvent(event), + })) + } else { + None + }; + + ops.push(GroupOperation(GroupOperationType::AddGroupAction { + contract_id: data_contract_id, + group_contract_position: *group_contract_position, + initialize_with_insert_action_info, + action_id: *action_id, + signer_identity_id: owner_id, + signer_power: *signer_power, + closes_group_action: self.base().perform_action(), + })); + } + + if self.base().perform_action() { + if listings.is_empty() { + ops.push(TokenOperation(TokenOperationType::TokenBurn { + token_id: self.token_id(), + identity_balance_holder_id: self.frozen_identity_id(), + burn_amount: self.amount(), + })); + } else { + let token_market_contract = self.token_market_contract().cloned().ok_or( + Error::Drive(DriveError::CorruptedCodeExecution( + "listings to destroy come with the token market contract", + )), + )?; + let document_ids = listings.iter().map(|(id, _)| *id).collect(); + ops.push(TokenOperation( + TokenOperationType::TokenDestroyFrozenHolding { + token_id: self.token_id(), + holder_id: self.frozen_identity_id(), + balance_amount: self.amount(), + listings, + }, + )); + ops.push(DocumentOperation(DocumentOperationType::DeleteDocuments { + document_ids, + contract_info: DataContractInfo::DataContractFetchInfo(token_market_contract), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( + TokenOrderSide::Ask.document_type_name(), + ), + })); + } + + ops.push(TokenOperation(TokenOperationType::TokenHistory { + token_id: self.token_id(), + owner_id, + nonce: identity_contract_nonce, + event: TokenEvent::DestroyFrozenFunds( + self.frozen_identity_id(), + destroyed_amount, + self.public_note_owned(), + ), + })); + } + + Ok(ops) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs index a81da66a06a..bf00b1f72e3 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs @@ -357,7 +357,7 @@ fn read_frozen_listings( transaction, platform_version, )?; - fee_result.processing_fee = fee_result.processing_fee.saturating_add(read_cost); + fee_result.checked_add_assign(FeeResult::new_from_processing_fee(read_cost))?; let token_market_contract = if listings.is_empty() { None } else { diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs index cd9f8bcefbf..26ab522494e 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs @@ -651,12 +651,71 @@ impl DocumentOperationType<'_> { batch_operations.extend(operations); } } - // A contested insert, a withdrawal and a document history entry: built as before, - // after the batch's earlier operations. Listed rather than matched with a wildcard, - // so a new operation type has to decide whether it is built against them. - operation @ (DocumentOperationType::AddContestedDocument { .. } - | DocumentOperationType::AddWithdrawalDocument { .. } - | DocumentOperationType::DocumentHistory { .. }) => { + DocumentOperationType::AddContestedDocument { + owned_document_info, + contested_document_resource_vote_poll, + contract_info, + document_type_info, + insert_without_check, + also_insert_vote_poll_stored_info, + } => { + let contract_resolved_info = contract_info.resolve( + drive, + block_info, + transaction, + batch_operations, + platform_version, + )?; + let contract = contract_resolved_info.as_ref(); + let document_type = document_type_info.resolve(contract)?; + let operations = drive.add_contested_document_for_contract_operations( + DocumentAndContractInfo { + owned_document_info, + contract, + document_type, + }, + contested_document_resource_vote_poll, + insert_without_check, + block_info, + also_insert_vote_poll_stored_info, + &mut Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + } + DocumentOperationType::AddWithdrawalDocument { + owned_document_info, + } => { + let contract = drive + .cache + .system_data_contracts + .load_withdrawals(platform_version)?; + let document_type = contract + .document_type_for_name(withdrawal::NAME) + .map_err(ProtocolError::DataContractError)?; + let operations = drive.add_document_for_contract_operations_without_ttl_drain( + DocumentAndContractInfo { + owned_document_info, + contract: &contract, + document_type, + }, + false, + block_info, + &mut Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + } + // A document history entry is built as before. Its API takes no earlier operations, + // and none can share its trees: a transition records at most one entry, keyed by its + // signer's nonce, and a batch carries one transition + // (`SystemLimits::max_transitions_in_documents_batch`). Listed rather than matched + // with a wildcard, so a new operation type has to decide how it is built. + operation @ DocumentOperationType::DocumentHistory { .. } => { let operations = operation.into_low_level_drive_operations_after_ttl_drain( drive, estimated_costs_only_with_layer_info, From ab5f6d4340b2c655606d0597d4e4bed1ed29681f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 25 Oct 2026 17:50:44 +0300 Subject: [PATCH 12/21] docs(platform-version): renumber the Frozen listings note after the merge Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-version/src/version/v15.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 794a1c2e20a..e728f041d1c 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -218,7 +218,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// v2. The marketplace transitions of a token that keeps transfer history write the matching /// history document. /// -/// 10. **Frozen listings**: a `TokenDestroyFrozenFunds` also destroys what the frozen identity's +/// 12. **Frozen listings**: a `TokenDestroyFrozenFunds` also destroys what the frozen identity's /// open listings of the token hold in escrow, the oldest first and at most /// `max_frozen_listings_destroyed_per_transition` (16) per transition, and deletes their /// `ask` documents. `DRIVE_TOKEN_METHOD_VERSIONS_V3` reads them From 414ab4cd29c46f006c01e0d59fd7629a3b95a708 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 00:43:05 +0300 Subject: [PATCH 13/21] refactor(drive): each destroyed-amount generation lives in its own module destroyed_amount dispatches to destroyed_amount_v0 (the frozen balance) and destroyed_amount_v1 (the balance and the listings' escrow, checked), each in its own version module, so an edit to the active generation cannot reach the recorded amount of an earlier one. Co-Authored-By: Claude Opus 5.5 --- .../destroyed_amount/mod.rs | 37 +++++++++++++++++++ .../destroyed_amount/v0/mod.rs | 9 +++++ .../destroyed_amount/v1/mod.rs | 15 ++++++++ .../mod.rs | 37 +------------------ 4 files changed, 62 insertions(+), 36 deletions(-) create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v1/mod.rs diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/mod.rs new file mode 100644 index 00000000000..0633c24fc6c --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/mod.rs @@ -0,0 +1,37 @@ +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionAction; +use dpp::balances::credits::TokenAmount; +use platform_version::version::PlatformVersion; + +mod v0; +mod v1; + +impl TokenDestroyFrozenFundsTransitionAction { + /// What the transition's history and group-action events record as destroyed, as the + /// converter generation `platform_version` selects + /// (`token_destroy_frozen_funds_transition`): the frozen balance alone in generation 0, and + /// the balance with what the listings hold in escrow from generation 1 (protocol version + /// 15). The converter and the shared builders (`associated_token_event`, + /// `build_historical_document`) all read it, so they record the same amount. + pub fn destroyed_amount( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_destroy_frozen_funds_transition + { + 0 => Ok(self.destroyed_amount_v0()), + 1 => self.destroyed_amount_v1(), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenDestroyFrozenFundsTransitionAction::destroyed_amount".to_string(), + known_versions: vec![0, 1], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v0/mod.rs new file mode 100644 index 00000000000..207998ade4d --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v0/mod.rs @@ -0,0 +1,9 @@ +use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; +use dpp::balances::credits::TokenAmount; + +impl TokenDestroyFrozenFundsTransitionAction { + /// Generation 0 (protocol version 14 and earlier): the frozen balance alone. + pub(super) fn destroyed_amount_v0(&self) -> TokenAmount { + self.amount() + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v1/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v1/mod.rs new file mode 100644 index 00000000000..a90158fbb05 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v1/mod.rs @@ -0,0 +1,15 @@ +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; +use dpp::balances::credits::TokenAmount; + +impl TokenDestroyFrozenFundsTransitionAction { + /// Generation 1 (protocol version 15): the frozen balance and what its listings hold in + /// escrow, summed with checked arithmetic. + pub(super) fn destroyed_amount_v1(&self) -> Result { + self.total_destroyed() + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a frozen holding is more tokens than a u64 holds", + ))) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs index a8f531f069a..2f7251c6c90 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs @@ -5,16 +5,14 @@ use dpp::identifier::Identifier; use dpp::tokens::token_order::TokenOrder; use std::sync::Arc; +mod destroyed_amount; /// transformer module for token destroy_frozen_funds transition action pub mod transformer; mod v0; pub use v0::*; // re-export the v0 module items (including TokenIssuanceTransitionActionV0) -use crate::error::drive::DriveError; -use crate::error::Error; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; -use platform_version::version::PlatformVersion; /// Token destroy_frozen_funds transition action #[derive(Debug, Clone, From)] @@ -93,39 +91,6 @@ impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 } } -impl TokenDestroyFrozenFundsTransitionAction { - /// What the transition's history and group-action events record as destroyed, as the - /// converter generation `platform_version` selects - /// (`token_destroy_frozen_funds_transition`): the frozen balance alone in generation 0, and - /// the balance with what the listings hold in escrow from generation 1 (protocol version - /// 15). The converter and the shared builders (`associated_token_event`, - /// `build_historical_document`) all read it, so they record the same amount. - pub fn destroyed_amount( - &self, - platform_version: &PlatformVersion, - ) -> Result { - match platform_version - .drive - .methods - .state_transitions - .convert_to_high_level_operations - .token_destroy_frozen_funds_transition - { - 0 => Ok(self.amount()), - 1 => self - .total_destroyed() - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "a frozen holding is more tokens than a u64 holds", - ))), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "TokenDestroyFrozenFundsTransitionAction::destroyed_amount".to_string(), - known_versions: vec![0, 1], - received: version, - })), - } - } -} - #[cfg(test)] mod tests { use super::*; From 8355a63991b0cd7b83d8d6d0ace7c73ae2e1f12e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 26 Oct 2026 09:28:09 +0300 Subject: [PATCH 14/21] fix(drive): the frozen-listing read fails closed before protocol version 15 fetch_frozen_token_listings returns VersionNotActive when its slot is None, as the other escrow dispatchers do; the destroy transformer reads nothing before protocol version 15 itself, so a destroy there still burns the frozen balance alone. Co-Authored-By: Claude Opus 5.5 --- .../fetch_frozen_token_listings/mod.rs | 46 +++++++++++-------- .../v0/transformer.rs | 12 +++++ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs index e9138a21359..b197694b69f 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs @@ -13,9 +13,9 @@ impl Drive { /// The open listings of `owner_id` for `token_id` that a `TokenDestroyFrozenFunds` destroys /// with the frozen balance: the oldest first, at most /// `max_frozen_listings_destroyed_per_transition`, read through the `ask` type's `byOwner` - /// index, with the processing cost of the read billed at `epoch`. None where - /// `fetch_frozen_listings` is `None`, which reads nothing: there the frozen balance alone is - /// destroyed, as before the marketplace. + /// index, with the processing cost of the read billed at `epoch`. `VersionNotActive` where + /// `fetch_frozen_listings` is `None` (before protocol version 15): a destroy there reads no + /// listings and does not call this. pub fn fetch_frozen_token_listings( &self, token_id: Identifier, @@ -31,7 +31,10 @@ impl Drive { .order_escrow .fetch_frozen_listings { - None => Ok((vec![], 0)), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_frozen_token_listings".to_string(), + known_versions: vec![0], + })), Some(0) => self.fetch_frozen_token_listings_v0( token_id, owner_id, @@ -50,28 +53,33 @@ impl Drive { #[cfg(test)] mod tests { + use crate::error::drive::DriveError; + use crate::error::Error; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::epoch::Epoch; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; - /// Before protocol version 15 a destroy of frozen funds reads no listings: on a chain with no - /// marketplace state (no escrow trees, no token market contract) the read returns none and - /// costs nothing. + /// Before protocol version 15 the read is not active: it fails closed rather than answering + /// with no listings, on a chain with no marketplace state (no escrow trees, no token market + /// contract). #[test] - fn should_read_no_frozen_listings_before_protocol_version_15() { + fn should_refuse_to_read_frozen_listings_before_protocol_version_15() { let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let (listings, cost) = drive - .fetch_frozen_token_listings( - Identifier::from([5; 32]), - Identifier::from([6; 32]), - &Epoch::new(0).expect("expected an epoch"), - None, - platform_version, - ) - .expect("expected the read to be skipped"); - assert!(listings.is_empty()); - assert_eq!(cost, 0); + let result = drive.fetch_frozen_token_listings( + Identifier::from([5; 32]), + Identifier::from([6; 32]), + &Epoch::new(0).expect("expected an epoch"), + None, + platform_version, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + ), + "{result:?}" + ); } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs index bf00b1f72e3..fe0b943d5f0 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs @@ -350,6 +350,18 @@ fn read_frozen_listings( transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(Vec, Option>), Error> { + // Before protocol version 15 there are no listings to destroy, and nothing is read: the + // frozen balance alone is destroyed, as it always was. + if platform_version + .drive + .methods + .token + .order_escrow + .fetch_frozen_listings + .is_none() + { + return Ok((vec![], None)); + } let (listings, read_cost) = drive.fetch_frozen_token_listings( token_id, frozen_identity_id, From 348e12dabef85f58ade66ed5c5b143b893f9af87 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 15:41:59 +0300 Subject: [PATCH 15/21] test(platform): frozen listing fixtures name no minimum fill or decimals Orders carry neither since the token sets them; the frozen listings note is note 13 of protocol version 15. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 9 ++------- .../token/token_destroy_frozen_funds_transition/mod.rs | 1 - .../drive_methods/apply_drive_operations/v2/mod.rs | 1 - packages/rs-platform-version/src/version/v15.rs | 2 +- 4 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index cd548a92bbc..8cc067086c9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -3558,7 +3558,6 @@ mod frozen { /// A listing by the buyer at block time `time_ms`, and its order id. async fn buyer_lists(&mut self, amount: u64, time_ms: u64) -> Identifier { let platform_version = PlatformVersion::latest(); - let decimals = self.token_decimals(); let nonce = self.buyer.take_nonce(); let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.buyer.id(), nonce); let place = BatchTransition::new_token_place_ask_transition( @@ -3568,8 +3567,6 @@ mod frozen { 0, amount, PRICE, - decimals, - 1, &self.buyer.key, nonce, 0, @@ -3866,7 +3863,7 @@ mod frozen { let frozen_listing = market.buyer_lists(3_000, 1).await; // Another holder's listing of the same token. - let (place, sellers_listing) = market.place_ask(500, PRICE, 1).await; + let (place, sellers_listing) = market.place_ask(500, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); // The frozen identity's listing of another token, of a contract it owns. @@ -3889,8 +3886,6 @@ mod frozen { 0, 400, PRICE, - market.token_decimals(), - 1, &market.buyer.key, other_nonce, 0, @@ -4004,7 +3999,7 @@ mod frozen { async fn should_leave_a_frozen_identitys_offers() { let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); market.give_buyer(100).await; - let (place, order_id) = market.place_bid(1_000, PRICE, 1).await; + let (place, order_id) = market.place_bid(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); market.freeze_buyer().await; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs index 4b43d9c8002..302b34011b0 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs @@ -165,7 +165,6 @@ mod tests { price: 1, amount: remaining, remaining, - min_fill: 1, escrow: 0, decimals: 0, } 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 index 031b0001ec6..16a11a711db 100644 --- 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 @@ -394,7 +394,6 @@ mod tests { ("price".to_string(), Value::I64(100)), ("amount".to_string(), Value::I64(10)), ("remaining".to_string(), Value::I64(10)), - ("minFill".to_string(), Value::I64(1)), ("decimals".to_string(), Value::I64(8)), ]), revision: Some(1), diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index a0bfc9119a6..f9f666354a7 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -251,7 +251,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// v2. The marketplace transitions of a token that keeps transfer history write the matching /// history document. /// -/// 12. **Frozen listings**: a `TokenDestroyFrozenFunds` also destroys what the frozen identity's +/// 13. **Frozen listings**: a `TokenDestroyFrozenFunds` also destroys what the frozen identity's /// open listings of the token hold in escrow, the oldest first and at most /// `max_frozen_listings_destroyed_per_transition` (16) per transition, and deletes their /// `ask` documents. `DRIVE_TOKEN_METHOD_VERSIONS_V3` reads them From ba04d796d357b38b13602a1a6d42d0ad1461d70d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 22:49:49 +0300 Subject: [PATCH 16/21] docs(changelog): list the drive API changes of the frozen listings destroy Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22df453c203..a949ac46206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,15 @@ changes, but struct literals need the fields. `DocumentTypeV0MutGetters` (`schema_mut`) is available only to tests and the `fixtures-and-mocks` feature. See [#5578](https://github.com/dashpay/platform/pull/5578). +- **drive (Rust API migration):** destroying a frozen identity's funds also destroys + its escrowed token listings from protocol version 15. + `TokenTransitionAction::associated_token_event` takes `&PlatformVersion` and returns + `Result`. `TokenDestroyFrozenFundsTransitionActionV0` gains + `escrowed_listings` and `token_market_contract`; struct literals set them to an empty + `Vec` and `None` before protocol version 15. `DocumentOperationType` gains + `DeleteDocuments` and `TokenOperationType` gains `TokenDestroyFrozenHolding`; neither + enum is `#[non_exhaustive]`, so exhaustive matches need new arms. See + [#5532](https://github.com/dashpay/platform/pull/5532). ## [5.0.0-rc.8](https://github.com/dashpay/platform/compare/v5.0.0-rc.7...v5.0.0-rc.8) (2026-10-23) From 47357f987092c26c8ee49b78a3ab86e15d59eef5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 27 Oct 2026 23:39:16 +0300 Subject: [PATCH 17/21] docs(changelog): list the platform-version fields of the frozen listings destroy Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a949ac46206..4cb166efcfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,12 @@ `DeleteDocuments` and `TokenOperationType` gains `TokenDestroyFrozenHolding`; neither enum is `#[non_exhaustive]`, so exhaustive matches need new arms. See [#5532](https://github.com/dashpay/platform/pull/5532). +- **platform-version (Rust API migration):** `DriveTokenOrderEscrowMethodVersions` gains + `fetch_frozen_listings` (`None` before protocol version 15, `Some(0)` from 15) and + `SystemLimits` gains `max_frozen_listings_destroyed_per_transition` (16 in every table; + nothing before protocol version 15 reads it). Struct literals of either, such as a + custom or test `PlatformVersion`, need the new field. See + [#5532](https://github.com/dashpay/platform/pull/5532). ## [5.0.0-rc.8](https://github.com/dashpay/platform/compare/v5.0.0-rc.7...v5.0.0-rc.8) (2026-10-23) From ca5e98f10f721271e377ce8510cf0dc86a6f2d70 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 28 Oct 2026 01:28:21 +0300 Subject: [PATCH 18/21] test(drive-abci): a cancel between a group destroy's signatures only grows the destroy The completing signature reads the frozen identity's listings again and destroys what it holds then, as generation 0 does with the balance. A frozen identity can only cancel a listing, which moves its escrow into the destroyed balance, so the completion never destroys less than proposed. Co-Authored-By: Claude Opus 5.5 --- .../batch/tests/token/marketplace/mod.rs | 179 ++++++++++++++++++ .../v0/transformer.rs | 6 + 2 files changed, 185 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 8cc067086c9..18d2d2df851 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -3509,6 +3509,7 @@ mod frozen { use super::*; use dpp::document::DocumentV0Getters; use dpp::fee::fee_result::FeeResult; + use dpp::state_transition::batch_transition::TokenDestroyFrozenFundsTransition; use drive::drive::RootTree; use drive::grovedb::query_result_type::{QueryResultElement, QueryResultType}; use drive::grovedb::{PathQuery, Query, SizedQuery}; @@ -3993,6 +3994,184 @@ mod frozen { market.assert_supply_balanced(); } + /// A market whose frozen funds only a group of the seller and a second identity destroys, + /// both signing (the seller still freezes alone), and that second identity. + fn market_destroyed_by_a_group() -> (Market, Party) { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let mut rng = StdRng::seed_from_u64(70415); + let mut new_party = |platform: &mut TempPlatform| { + let (identity, signer, key) = + setup_identity(platform, rng.gen(), dash_to_credits!(1.0)); + Party { + identity, + signer, + key, + nonce: 2, + } + }; + let seller = new_party(&mut platform); + let buyer = new_party(&mut platform); + let second = new_party(&mut platform); + let members = [(seller.id(), 1), (second.id(), 1)].into(); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + seller.id(), + Some(|token_configuration: &mut TokenConfiguration| { + make_token_tradeable_and_freezable(token_configuration); + token_configuration.set_destroy_frozen_funds_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members, + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + ( + Market { + platform, + contract, + token_id, + seller, + buyer, + }, + second, + ) + } + + /// A group destroy reads the frozen identity's listings again at the signature that + /// completes it, and destroys what the frozen identity holds then: the amount the proposal + /// recorded is not a bound. All a frozen identity can still do with a listing is cancel it, + /// which moves the escrow into the balance the completion destroys anyway, so a cancel + /// between the signatures only makes the destroy larger. Here the cancelled oldest listing + /// comes back through the balance, and the newest, past the limit at the proposal, is + /// destroyed too. Refusing a completion whose amount differs from the proposal's would let + /// the frozen identity block every group destroy with one cancel. + #[tokio::test] + async fn should_destroy_at_least_what_a_group_proposed_after_the_frozen_identity_cancels() { + let platform_version = PlatformVersion::latest(); + let limit = platform_version + .system_limits + .max_frozen_listings_destroyed_per_transition as usize; + let (mut market, mut second) = market_destroyed_by_a_group(); + // Listings of 10, 20, ... base units, one more than a destroy takes, and 100 in the balance + let amounts: Vec = (1..=limit as u64 + 1).map(|i| 10 * i).collect(); + let given = amounts.iter().sum::() + 100; + market.give_buyer(given).await; + let mut listings = vec![]; + for (time_ms, amount) in (1..).zip(&amounts) { + listings.push(market.buyer_lists(*amount, time_ms).await); + } + market.freeze_buyer().await; + + // The seller proposes: the oldest `limit` listings and the balance, nothing destroyed yet + let proposed = 100 + amounts[..limit].iter().sum::(); + let proposer_nonce = market.seller.take_nonce(); + let propose = BatchTransition::new_token_destroy_frozen_funds_transition( + market.token_id, + market.seller.id(), + market.contract.id(), + 0, + market.buyer.id(), + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &market.seller.key, + proposer_nonce, + 0, + &market.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the proposal"); + market.assert_executes(&propose, &at_time(1_000_000)); + assert_eq!(market.supply(), BASE_SUPPLY); + assert_eq!(market.escrow(listings[0]), Some(10)); + + // The frozen identity cancels its oldest listing, whose escrow returns to its balance + let cancel = market.cancel(listings[0], true).await; + market.assert_executes(&cancel, &at_time(1_000_001)); + assert_eq!(market.token_balance(market.buyer.id()), Some(110)); + + // The second member completes the destroy + let action_id = TokenDestroyFrozenFundsTransition::calculate_action_id_with_fields( + market.token_id.as_bytes(), + market.seller.id().as_bytes(), + proposer_nonce, + market.buyer.id().as_bytes(), + ); + let second_nonce = second.take_nonce(); + let complete = BatchTransition::new_token_destroy_frozen_funds_transition( + market.token_id, + second.id(), + market.contract.id(), + 0, + market.buyer.id(), + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &second.key, + second_nonce, + 0, + &second.signer, + platform_version, + None, + ) + .await + .expect("expected to build the completion"); + market.assert_executes(&complete, &at_time(1_000_002)); + + assert_eq!(market.token_balance(market.buyer.id()), Some(0)); + for order_id in &listings[1..] { + assert_eq!( + market.escrow(*order_id), + None, + "every listing left is destroyed" + ); + assert_eq!(market.listing(*order_id), None); + } + assert!(given > proposed, "the destroy grew with the cancel"); + assert_eq!(market.supply(), BASE_SUPPLY - given); + market.assert_supply_balanced(); + let records = market.history("destroyFrozenFunds"); + assert_eq!( + records + .last() + .and_then(|record| record.properties().get("destroyedAmount")) + .and_then(|amount| amount.to_integer::().ok()), + Some(given), + "the record counts what the completion destroyed" + ); + } + /// An offer holds credits, which are not the token issuer's to destroy: it stays, and the /// frozen identity can still withdraw it. #[tokio::test] diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs index fe0b943d5f0..8ed9e9279d6 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs @@ -341,6 +341,12 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { /// any, the token market contract their `ask` documents are deleted from. From protocol version /// 15 the listings are destroyed with the balance, as what they hold sits in escrow, out of the /// balance's reach; earlier versions read none. The read is billed to `fee_result`. +/// +/// A group destroy reads them at every signature, and the one that completes it destroys what is +/// there then, as it does the balance: the amount the proposal recorded is not a bound. A frozen +/// identity can only cancel a listing, which moves its escrow into that balance, so a cancel +/// between the signatures can only grow the destroy, never shrink it; refusing a completion +/// whose amount changed would let the frozen identity block every group destroy with one cancel. fn read_frozen_listings( drive: &Drive, token_id: Identifier, From 5d183486007a4d07c2c0d5c02c2b8f354d9769d5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 28 Oct 2026 02:54:44 +0300 Subject: [PATCH 19/21] fix(drive): the protocol 15 raw converter refuses repeated token writes; destroy generation 0 in its own module convert_drive_operations_to_grove_operations 2 refuses a batch writing one token balance, supply, pool total or escrow twice before lowering it, as apply_drive_operations does. The destroy-frozen-funds converter's generation 0 moves unchanged from the dispatcher into v0/mod.rs. Co-Authored-By: Claude Opus 5.5 --- .../mod.rs | 89 +++--------------- .../v0/mod.rs | 91 +++++++++++++++++++ .../apply_drive_operations/v2/mod.rs | 41 +++++++++ .../v2/mod.rs | 7 +- .../src/version/drive_versions/v10.rs | 2 +- 5 files changed, 151 insertions(+), 79 deletions(-) create mode 100644 packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v0/mod.rs diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs index 302b34011b0..2281a566319 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs @@ -1,20 +1,13 @@ use dpp::block::epoch::Epoch; -use dpp::group::action_event::GroupActionEvent; -use dpp::group::group_action::GroupAction; -use dpp::group::group_action::v0::GroupActionV0; -use dpp::group::GroupStateTransitionResolvedInfo; use dpp::identifier::Identifier; -use dpp::tokens::token_event::TokenEvent; use platform_version::version::PlatformVersion; use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; -use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; -use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; -use crate::util::batch::{DriveOperation, IdentityOperationType}; -use crate::util::batch::drive_op_batch::{GroupOperationType, TokenOperationType}; -use crate::util::batch::DriveOperation::{GroupOperation, IdentityOperation, TokenOperation}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionAction; +use crate::util::batch::DriveOperation; +mod v0; mod v1; impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransitionAction { @@ -31,73 +24,7 @@ impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransition .convert_to_high_level_operations .token_destroy_frozen_funds_transition { - 0 => { - let data_contract_id = self.base().data_contract_id(); - - let identity_contract_nonce = self.base().identity_contract_nonce(); - - let mut ops = vec![IdentityOperation( - IdentityOperationType::UpdateIdentityContractNonce { - identity_id: owner_id.into_buffer(), - contract_id: data_contract_id.into_buffer(), - nonce: identity_contract_nonce, - }, - )]; - - if let Some(GroupStateTransitionResolvedInfo { - group_contract_position, - action_id, - action_is_proposer, - signer_power, - .. - }) = self.base().store_in_group() - { - let event = - TokenEvent::DestroyFrozenFunds(self.frozen_identity_id(), self.amount(), self.public_note().cloned()); - - let initialize_with_insert_action_info = if *action_is_proposer { - Some(GroupAction::V0(GroupActionV0 { - contract_id: self.base().data_contract_id(), - proposer_id: owner_id, - token_contract_position: self.base().token_position(), - event: GroupActionEvent::TokenEvent(event), - })) - } else { - None - }; - - ops.push(GroupOperation(GroupOperationType::AddGroupAction { - contract_id: data_contract_id, - group_contract_position: *group_contract_position, - initialize_with_insert_action_info, - action_id: *action_id, - signer_identity_id: owner_id, - signer_power: *signer_power, - closes_group_action: self.base().perform_action(), - })); - } - - if self.base().perform_action() { - ops.push(TokenOperation(TokenOperationType::TokenBurn { - token_id: self.token_id(), - identity_balance_holder_id: self.frozen_identity_id(), - burn_amount: self.amount(), - })); - - ops.push(TokenOperation(TokenOperationType::TokenHistory { - token_id: self.token_id(), - owner_id, - nonce: identity_contract_nonce, - event: TokenEvent::DestroyFrozenFunds( - self.frozen_identity_id(), - self.amount(), - self.public_note_owned(), - ), - })); - } - - Ok(ops) - } + 0 => self.destroy_frozen_funds_operations_v0(owner_id), // From protocol version 15: also destroys what the frozen identity's open listings // hold in escrow, and deletes their `ask` documents. 1 => self.destroy_frozen_funds_and_listings_operations_v1(owner_id, platform_version), @@ -114,6 +41,14 @@ impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransition #[cfg(test)] mod tests { use super::*; + use dpp::group::action_event::GroupActionEvent; + use dpp::group::group_action::GroupAction; + use dpp::group::group_action::v0::GroupActionV0; + use dpp::group::GroupStateTransitionResolvedInfo; + use dpp::tokens::token_event::TokenEvent; + use crate::util::batch::drive_op_batch::{GroupOperationType, TokenOperationType}; + use crate::util::batch::DriveOperation::{GroupOperation, IdentityOperation, TokenOperation}; + use crate::util::batch::IdentityOperationType; use crate::util::batch::DocumentOperationType; use crate::util::batch::DriveOperation::DocumentOperation; use dpp::balances::credits::TokenAmount; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v0/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v0/mod.rs new file mode 100644 index 00000000000..7768d3a36d5 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v0/mod.rs @@ -0,0 +1,91 @@ +use dpp::group::action_event::GroupActionEvent; +use dpp::group::group_action::GroupAction; +use dpp::group::group_action::v0::GroupActionV0; +use dpp::group::GroupStateTransitionResolvedInfo; +use dpp::identifier::Identifier; +use dpp::tokens::token_event::TokenEvent; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; +use crate::util::batch::{DriveOperation, IdentityOperationType}; +use crate::util::batch::drive_op_batch::{GroupOperationType, TokenOperationType}; +use crate::util::batch::DriveOperation::{GroupOperation, IdentityOperation, TokenOperation}; + +impl TokenDestroyFrozenFundsTransitionAction { + /// Generation 0 (protocol version 14 and earlier): bumps the signer's nonce, records the + /// signature of a group action, and when the action is performed burns the frozen balance + /// and records the destroy in the token's history. + pub(super) fn destroy_frozen_funds_operations_v0<'b>( + self, + owner_id: Identifier, + ) -> Result>, Error> { + let data_contract_id = self.base().data_contract_id(); + + let identity_contract_nonce = self.base().identity_contract_nonce(); + + let mut ops = vec![IdentityOperation( + IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }, + )]; + + if let Some(GroupStateTransitionResolvedInfo { + group_contract_position, + action_id, + action_is_proposer, + signer_power, + .. + }) = self.base().store_in_group() + { + let event = TokenEvent::DestroyFrozenFunds( + self.frozen_identity_id(), + self.amount(), + self.public_note().cloned(), + ); + + let initialize_with_insert_action_info = if *action_is_proposer { + Some(GroupAction::V0(GroupActionV0 { + contract_id: self.base().data_contract_id(), + proposer_id: owner_id, + token_contract_position: self.base().token_position(), + event: GroupActionEvent::TokenEvent(event), + })) + } else { + None + }; + + ops.push(GroupOperation(GroupOperationType::AddGroupAction { + contract_id: data_contract_id, + group_contract_position: *group_contract_position, + initialize_with_insert_action_info, + action_id: *action_id, + signer_identity_id: owner_id, + signer_power: *signer_power, + closes_group_action: self.base().perform_action(), + })); + } + + if self.base().perform_action() { + ops.push(TokenOperation(TokenOperationType::TokenBurn { + token_id: self.token_id(), + identity_balance_holder_id: self.frozen_identity_id(), + burn_amount: self.amount(), + })); + + ops.push(TokenOperation(TokenOperationType::TokenHistory { + token_id: self.token_id(), + owner_id, + nonce: identity_contract_nonce, + event: TokenEvent::DestroyFrozenFunds( + self.frozen_identity_id(), + self.amount(), + self.public_note_owned(), + ), + })); + } + + Ok(ops) + } +} 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 index 16a11a711db..c063e46701c 100644 --- 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 @@ -246,6 +246,8 @@ impl Drive { #[cfg(test)] mod tests { use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; use crate::util::batch::drive_op_batch::{ PrefundedSpecializedBalanceOperationType, TokenOperationType, }; @@ -339,6 +341,45 @@ mod tests { ); } + /// From protocol version 15 the converter that returns a batch instead of applying it + /// refuses a batch writing one token key twice, as this generation does: a frozen holding + /// destroy naming one listing twice, and two destroys of one holding. + #[test] + fn should_refuse_repeated_token_writes_through_the_raw_converter() { + let latest = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(latest)); + let destroy = |listings: Vec<(Identifier, u64)>| { + DriveOperation::TokenOperation(TokenOperationType::TokenDestroyFrozenHolding { + token_id: Identifier::from([5; 32]), + holder_id: Identifier::from([6; 32]), + balance_amount: 10, + listings, + }) + }; + let listing = Identifier::from([7; 32]); + for (case, operations) in [ + ( + "one listing twice", + vec![destroy(vec![(listing, 1), (listing, 1)])], + ), + ("one holding twice", vec![destroy(vec![]), destroy(vec![])]), + ] { + let refused = drive.convert_drive_operations_to_grove_operations( + operations, + &BlockInfo::default(), + None, + latest, + ); + assert!( + matches!( + refused, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "{case}: {refused:?}" + ); + } + } + mod batch_document_writes { use crate::drive::{Drive, RootTree}; use crate::util::batch::drive_op_batch::{ diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs index 628094b05e3..29580e6edb6 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs @@ -15,7 +15,11 @@ impl Drive { /// ([`DriveOperation::append_low_level_drive_operations_after_ttl_drain`]). Two deletions /// that empty an index tree together remove it, and an insert into a tree an earlier deletion /// of the batch empties keeps the tree. Credits that repaid an identity's debt and ephemeral - /// operations are refused as by version 0, for the same reasons. + /// operations are refused as by version 0, for the same reasons. A batch writing one token + /// balance, supply, pool total or escrow more than once is refused first, as + /// `apply_drive_operations` refuses it + /// ([`DriveOperation::refuse_repeated_token_balance_writes`]): each such write sets the value + /// computed from state before the batch, so all but the last would be lost. #[inline(always)] pub(super) fn convert_drive_operations_to_grove_operations_v2( &self, @@ -24,6 +28,7 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { + DriveOperation::refuse_repeated_token_balance_writes(&drive_batch_operations)?; self.refuse_repeated_counter_moves( &drive_batch_operations, block_info, diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index e6b47aab98f..b337d0e57d7 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -67,7 +67,7 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { ..DRIVE_IDENTITY_METHOD_VERSIONS_V2 }, batch_operations: DriveBatchOperationsMethodVersion { - convert_drive_operations_to_grove_operations: 2, // changed in v10: each operation is built against the batch's earlier ones + convert_drive_operations_to_grove_operations: 2, // changed in v10: each operation is built against the batch's earlier ones, and a batch writing one token balance, supply, pool total or escrow twice is refused apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions into one covering both }, prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { From 424a32553e9b81b72bbfbdbbcbd3f0573e5b200b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 28 Oct 2026 23:16:08 +0300 Subject: [PATCH 20/21] fix(drive)!: apply the frozen listings review: merged balance writes, one listings field, versioned tables - convert_drive_operations_to_grove_operations 2 merges repeated balance writes as apply_drive_operations does, with tests of its own and a v1/v2 dispatcher test; it has no production caller at protocol 15 - the docs say only document operations are built against the batch - TokenDestroyFrozenFundsTransitionActionV0 carries one frozen_listings: Option; destroyed_amount is one unversioned checked sum and associated_token_event takes no version - the converter's generation 1 delegates to generation 0 without listings, deletes each listing with DeleteDocument (DeleteDocuments is removed: at protocol 15 separate deletes see each other), and passes the destroyed total to TokenDestroyFrozenHolding, which refuses a burn generation it does not know - a group signature that neither proposes nor completes reads no listings - max_frozen_listings_destroyed_per_transition is Option, Some(16) only in SYSTEM_LIMITS_V5; DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5 uses struct update syntax; the version docs name the batch-context change - tests: the destroy through the transformer at protocol 14, the drain inverse shapes at 14 and 15, the proposal's recorded amount, shared test fixtures Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 +- book/src/data-model/token-marketplace.md | 5 +- .../tests/token/destroy_frozen_funds/mod.rs | 21 +- .../batch/tests/token/marketplace/mod.rs | 333 ++++++------------ .../v0/tests/batched_group_drain.rs | 250 ++++++------- .../summable_off_count_index_e2e_tests.rs | 41 ++- .../fetch_frozen_token_listings/mod.rs | 17 +- .../fetch_frozen_token_listings/v0/mod.rs | 6 +- .../mod.rs | 119 ++++--- .../v0/mod.rs | 2 +- .../v1/mod.rs | 73 ++-- .../batch/token/token_transition.rs | 19 +- .../token_transition/mod.rs | 2 +- .../destroyed_amount/mod.rs | 37 -- .../destroyed_amount/v0/mod.rs | 9 - .../destroyed_amount/v1/mod.rs | 15 - .../mod.rs | 40 ++- .../v0/mod.rs | 49 ++- .../v0/transformer.rs | 63 ++-- .../token_transition_action_type.rs | 6 +- .../state_transition_action/batch/tests.rs | 3 +- .../src/util/batch/drive_op_batch/document.rs | 150 ++------ .../apply_drive_operations/v2/mod.rs | 195 ++++------ .../v2/mod.rs | 137 ++++++- .../src/util/batch/drive_op_batch/mod.rs | 6 +- .../src/util/batch/drive_op_batch/token.rs | 44 ++- .../rs-drive/src/util/test_helpers/mod.rs | 45 ++- .../v0/mod.rs | 7 +- .../v5.rs | 90 +---- .../src/version/drive_versions/v10.rs | 12 +- .../src/version/mocks/v2_test.rs | 2 +- .../src/version/system_limits/mod.rs | 13 +- .../src/version/system_limits/v1.rs | 2 +- .../src/version/system_limits/v2.rs | 2 +- .../src/version/system_limits/v3.rs | 2 +- .../src/version/system_limits/v4.rs | 6 +- .../src/version/system_limits/v5.rs | 4 + .../rs-platform-version/src/version/v15.rs | 16 +- 38 files changed, 856 insertions(+), 1006 deletions(-) delete mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/mod.rs delete mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v0/mod.rs delete mode 100644 packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v1/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c740a84d6..7de8835a45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,18 +106,19 @@ need them. See [#5579](https://github.com/dashpay/platform/pull/5579). - **drive (Rust API migration):** destroying a frozen identity's funds also destroys its escrowed token listings from protocol version 15. - `TokenTransitionAction::associated_token_event` takes `&PlatformVersion` and returns - `Result`. `TokenDestroyFrozenFundsTransitionActionV0` gains - `escrowed_listings` and `token_market_contract`; struct literals set them to an empty - `Vec` and `None` before protocol version 15. `DocumentOperationType` gains - `DeleteDocuments` and `TokenOperationType` gains `TokenDestroyFrozenHolding`; neither - enum is `#[non_exhaustive]`, so exhaustive matches need new arms. See + `TokenTransitionAction::associated_token_event` returns `Result`, + failing only for a destroy whose total overflows. `TokenDestroyFrozenFundsTransitionActionV0` + gains `frozen_listings: Option` (the listings and the token market + contract); struct literals set it to `None` before protocol version 15. + `TokenOperationType` gains `TokenDestroyFrozenHolding`; it is not `#[non_exhaustive]`, so + exhaustive matches need a new arm. `convert_drive_operations_to_grove_operations` merges + repeated balance writes from protocol version 15, as `apply_drive_operations` does. See [#5532](https://github.com/dashpay/platform/pull/5532). - **platform-version (Rust API migration):** `DriveTokenOrderEscrowMethodVersions` gains `fetch_frozen_listings` (`None` before protocol version 15, `Some(0)` from 15) and - `SystemLimits` gains `max_frozen_listings_destroyed_per_transition` (16 in every table; - nothing before protocol version 15 reads it). Struct literals of either, such as a - custom or test `PlatformVersion`, need the new field. See + `SystemLimits` gains `max_frozen_listings_destroyed_per_transition: Option` (`None` + before protocol version 15, `Some(16)` in `SYSTEM_LIMITS_V5`). Struct literals of either, + such as a custom or test `PlatformVersion`, need the new field. See [#5532](https://github.com/dashpay/platform/pull/5532). ### [5.0.1](https://github.com/dashpay/platform/compare/v5.0.0...v5.0.1) (2026-10-27) diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 1c34b4efb99..0d19045a837 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -263,7 +263,10 @@ signer of each transition owns the record and pays its storage, as for a transfe The records keep an order's price scale: `decimals` is the token's when the order was placed, which priced every fill of it, so a closed order reads correctly after a later change of the token's decimals. A placement's record holds every term of the transition, so it proves the placement once -the order has closed (filled entirely or cancelled). +the order has closed (filled entirely, cancelled, or destroyed with a frozen holding). A destroy of +a frozen identity's listings writes no record per order: its `destroyFrozenFunds` record holds the +whole amount destroyed, not the orders it closed, so an order destroyed that way keeps its +`orderPlaced` record with no closing one. Each type is indexed by token and date, by signer, and by order (`byOrder`); `orderFilled` also by maker (`byMakerId`), so an identity can list the fills of its own orders. The upgrade to 15 diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/destroy_frozen_funds/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/destroy_frozen_funds/mod.rs index 8db8e815b0a..ebbf6d91044 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/destroy_frozen_funds/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/destroy_frozen_funds/mod.rs @@ -10,9 +10,26 @@ mod token_destroy_frozen_funds_tests { #[tokio::test] async fn test_token_destroy_frozen_funds_success() { - let platform_version = PlatformVersion::latest(); + destroy_frozen_funds_of_a_frozen_identity(PlatformVersion::latest()).await; + } + + /// The destroy at protocol version 14, which mainnet runs: the shared transformer reads no + /// listings there (`fetch_frozen_listings` is `None`, and reading them would fail with + /// `VersionNotActive`), and the frozen balance alone is burned. + #[tokio::test] + async fn should_destroy_frozen_funds_through_the_transformer_at_protocol_version_14() { + destroy_frozen_funds_of_a_frozen_identity( + PlatformVersion::get(14).expect("expected protocol version 14"), + ) + .await; + } + + /// Mints 5,000 to a second identity, freezes it and destroys its frozen funds at + /// `platform_version`: the balance is burned to 0, the supply drops by 5,000 and the account + /// stays frozen. + async fn destroy_frozen_funds_of_a_frozen_identity(platform_version: &PlatformVersion) { let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(platform_version.protocol_version) .build_with_mock_rpc() .set_genesis_state(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs index 18d2d2df851..bc71d472dbf 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -121,6 +121,16 @@ fn market_with( token_configuration_modification: impl FnOnce(&mut TokenConfiguration), buyer_credits: Credits, ) -> Market { + market_with_member(token_configuration_modification, buyer_credits, false).0 +} + +/// `market_with`, and when `with_member` a third identity that forms the contract's group 0 +/// with the seller, each with power 1 of the 2 the group requires. +fn market_with_member( + token_configuration_modification: impl FnOnce(&mut TokenConfiguration), + buyer_credits: Credits, + with_member: bool, +) -> (Market, Option) { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() .with_latest_protocol_version() @@ -128,37 +138,49 @@ fn market_with( .set_genesis_state(); let mut rng = StdRng::seed_from_u64(70415); - let (seller, seller_signer, seller_key) = - setup_identity(&mut platform, rng.gen(), dash_to_credits!(1.0)); - let (buyer, buyer_signer, buyer_key) = setup_identity(&mut platform, rng.gen(), buyer_credits); + let mut new_party = |platform: &mut TempPlatform, credits: Credits| { + let (identity, signer, key) = setup_identity(platform, rng.gen(), credits); + Party { + identity, + signer, + key, + nonce: 2, + } + }; + let seller = new_party(&mut platform, dash_to_credits!(1.0)); + let buyer = new_party(&mut platform, buyer_credits); + let member = with_member.then(|| new_party(&mut platform, dash_to_credits!(1.0))); + let groups = member.as_ref().map(|member| { + [( + 0, + Group::V0(GroupV0 { + members: [(seller.id(), 1), (member.id(), 1)].into(), + required_power: 2, + }), + )] + .into() + }); let (contract, token_id) = create_token_contract_with_owner_identity( &mut platform, seller.id(), Some(token_configuration_modification), None, - None, + groups, None, platform_version, ); - Market { - platform, - contract, - token_id, - seller: Party { - identity: seller, - signer: seller_signer, - key: seller_key, - nonce: 2, + ( + Market { + platform, + contract, + token_id, + seller, + buyer, }, - buyer: Party { - identity: buyer, - signer: buyer_signer, - key: buyer_key, - nonce: 2, - }, - } + member, + ) } /// The guarantee the proof of a marketplace transition gives. A placement's binds its @@ -3509,10 +3531,12 @@ mod frozen { use super::*; use dpp::document::DocumentV0Getters; use dpp::fee::fee_result::FeeResult; + use dpp::group::action_event::GroupActionEvent; + use dpp::group::group_action::GroupActionAccessors; use dpp::state_transition::batch_transition::TokenDestroyFrozenFundsTransition; + use dpp::tokens::token_event::TokenEvent; use drive::drive::RootTree; - use drive::grovedb::query_result_type::{QueryResultElement, QueryResultType}; - use drive::grovedb::{PathQuery, Query, SizedQuery}; + use drive::util::test_helpers::subtree_paths; use std::collections::BTreeSet; /// The contract owner (the seller) may freeze an account and destroy its frozen funds. @@ -3530,103 +3554,16 @@ mod frozen { } impl Market { - /// The seller transfers `amount` to the buyer. - async fn give_buyer(&mut self, amount: u64) { - let platform_version = PlatformVersion::latest(); - let nonce = self.seller.take_nonce(); - let transfer = BatchTransition::new_token_transfer_transition( - self.token_id, - self.seller.id(), - self.contract.id(), - 0, - amount, - self.buyer.id(), - None, - None, - None, - &self.seller.key, - nonce, - 0, - &self.seller.signer, - platform_version, - None, - ) - .await - .expect("expected to build the transfer"); - self.assert_executes(&transfer, &BlockInfo::default()); - } - /// A listing by the buyer at block time `time_ms`, and its order id. async fn buyer_lists(&mut self, amount: u64, time_ms: u64) -> Identifier { - let platform_version = PlatformVersion::latest(); - let nonce = self.buyer.take_nonce(); - let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.buyer.id(), nonce); - let place = BatchTransition::new_token_place_ask_transition( - self.token_id, - self.buyer.id(), - self.contract.id(), - 0, - amount, - PRICE, - &self.buyer.key, - nonce, - 0, - &self.buyer.signer, - platform_version, - None, - ) - .await - .expect("expected to build the listing"); + let (place, order_id) = self.buyer_places_ask(amount, PRICE).await; self.assert_executes(&place, &at_time(time_ms)); order_id } - /// The seller, the contract owner, freezes the buyer. - async fn freeze_buyer(&mut self) { - let platform_version = PlatformVersion::latest(); - let nonce = self.seller.take_nonce(); - let freeze = BatchTransition::new_token_freeze_transition( - self.token_id, - self.seller.id(), - self.contract.id(), - 0, - self.buyer.id(), - None, - None, - &self.seller.key, - nonce, - 0, - &self.seller.signer, - platform_version, - None, - ) - .await - .expect("expected to build the freeze"); - self.assert_executes(&freeze, &BlockInfo::default()); - } - /// The seller destroys the buyer's frozen funds. async fn destroy_buyers_frozen_funds(&mut self) { - let platform_version = PlatformVersion::latest(); - let nonce = self.seller.take_nonce(); - let destroy = BatchTransition::new_token_destroy_frozen_funds_transition( - self.token_id, - self.seller.id(), - self.contract.id(), - 0, - self.buyer.id(), - None, - None, - &self.seller.key, - nonce, - 0, - &self.seller.signer, - platform_version, - None, - ) - .await - .expect("expected to build the destroy"); - self.assert_executes(&destroy, &at_time(1_000_000)); + self.destroy_buyers_frozen_funds_charged().await; } fn supply(&self) -> u64 { @@ -3656,46 +3593,16 @@ mod frozen { /// Every tree and item under the token market contract's `ask` document type: its /// documents and their index entries, by path. fn ask_trees(&self) -> BTreeSet>> { - let platform_version = PlatformVersion::latest(); - let root = vec![ - vec![RootTree::DataContractDocuments as u8], - SystemDataContract::TokenMarket.id().to_vec(), - vec![1u8], - TokenOrderSide::Ask.document_type_name().as_bytes().to_vec(), - ]; - let mut paths = BTreeSet::new(); - let mut pending = vec![root]; - while let Some(path) = pending.pop() { - let mut query = Query::new(); - query.insert_all(); - let (rows, _) = self - .platform - .drive - .grove - .query_raw( - &PathQuery::new(path.clone(), SizedQuery::new(query, None, None)), - true, - true, - true, - QueryResultType::QueryKeyElementPairResultType, - None, - &platform_version.drive.grove_version, - ) - .value - .expect("expected to read the ask trees"); - for row in rows.elements { - let QueryResultElement::KeyElementPairResultItem((key, element)) = row else { - panic!("expected a key and an element"); - }; - let mut child = path.clone(); - child.push(key); - if element.is_any_tree() { - pending.push(child.clone()); - } - paths.insert(child); - } - } - paths + subtree_paths( + &self.platform.drive, + vec![ + vec![RootTree::DataContractDocuments as u8], + SystemDataContract::TokenMarket.id().to_vec(), + vec![1u8], + TokenOrderSide::Ask.document_type_name().as_bytes().to_vec(), + ], + PlatformVersion::latest(), + ) } /// The seller destroys the buyer's frozen funds, and the fee it was charged. @@ -3729,16 +3636,15 @@ mod frozen { /// the other, so the trees they shared are removed. The `ask` trees left are the ones /// cancelling the two listings one by one leaves, and the frozen owner gets back at least /// the storage those cancels refund (the destroy also shrinks the balance it burns to 0, where - /// a cancel grows it). Built as separate deletes, 16 of the 24 trees stayed instead of 3, and - /// the refund was about half. + /// a cancel grows it). #[tokio::test] async fn should_remove_and_refund_the_index_trees_destroyed_listings_shared() { let mut destroyed = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); - destroyed.give_buyer(10_000).await; + destroyed.transfer_to_buyer(10_000).await; destroyed.buyer_lists(3_000, 1).await; destroyed.buyer_lists(2_000, 1).await; let trees_with_listings = destroyed.ask_trees(); - destroyed.freeze_buyer().await; + destroyed.freeze_the_buyer().await; let fee = destroyed.destroy_buyers_frozen_funds_charged().await; let destroy_refund = fee .fee_refunds @@ -3746,7 +3652,7 @@ mod frozen { .unwrap_or_default(); let mut cancelled = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); - cancelled.give_buyer(10_000).await; + cancelled.transfer_to_buyer(10_000).await; let first = cancelled.buyer_lists(3_000, 1).await; let second = cancelled.buyer_lists(2_000, 1).await; assert_eq!(cancelled.ask_trees(), trees_with_listings); @@ -3785,7 +3691,7 @@ mod frozen { #[tokio::test] async fn should_destroy_what_is_left_of_a_partly_filled_listing() { let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); - market.give_buyer(8_000).await; + market.transfer_to_buyer(8_000).await; let order_id = market.buyer_lists(3_000, 1).await; let fill = market.seller_fills_ask(order_id, 1_000).await; market.assert_executes(&fill, &BlockInfo::default()); @@ -3795,7 +3701,7 @@ mod frozen { .token_balance(market.seller.id()) .expect("expected the seller's balance"); let supply = market.supply(); - market.freeze_buyer().await; + market.freeze_the_buyer().await; market.destroy_buyers_frozen_funds().await; @@ -3825,11 +3731,11 @@ mod frozen { #[tokio::test] async fn should_destroy_a_frozen_identitys_listings_with_its_balance() { let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); - market.give_buyer(10_000).await; + market.transfer_to_buyer(10_000).await; let first = market.buyer_lists(3_000, 1).await; let second = market.buyer_lists(2_000, 2).await; assert_eq!(market.token_balance(market.buyer.id()), Some(5_000)); - market.freeze_buyer().await; + market.freeze_the_buyer().await; market.destroy_buyers_frozen_funds().await; @@ -3860,7 +3766,7 @@ mod frozen { async fn should_leave_other_owners_listings_and_other_tokens_listings() { let platform_version = PlatformVersion::latest(); let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); - market.give_buyer(10_000).await; + market.transfer_to_buyer(10_000).await; let frozen_listing = market.buyer_lists(3_000, 1).await; // Another holder's listing of the same token. @@ -3920,7 +3826,7 @@ mod frozen { let other_supply_before = other_supply(&market); assert_eq!(other_escrow(&market), Some(400)); - market.freeze_buyer().await; + market.freeze_the_buyer().await; market.destroy_buyers_frozen_funds().await; assert_eq!( @@ -3967,14 +3873,15 @@ mod frozen { async fn should_destroy_the_oldest_listings_up_to_the_limit_per_transition() { let limit = PlatformVersion::latest() .system_limits - .max_frozen_listings_destroyed_per_transition as u64; + .max_frozen_listings_destroyed_per_transition + .expect("expected the limit at the latest version") as u64; let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); - market.give_buyer(10 * (limit + 1)).await; + market.transfer_to_buyer(10 * (limit + 1)).await; let mut listings = vec![]; for time_ms in 1..=limit + 1 { listings.push(market.buyer_lists(10, time_ms).await); } - market.freeze_buyer().await; + market.freeze_the_buyer().await; market.destroy_buyers_frozen_funds().await; let newest = listings.pop().expect("expected the newest listing"); @@ -3997,31 +3904,8 @@ mod frozen { /// A market whose frozen funds only a group of the seller and a second identity destroys, /// both signing (the seller still freezes alone), and that second identity. fn market_destroyed_by_a_group() -> (Market, Party) { - let platform_version = PlatformVersion::latest(); - let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc() - .set_genesis_state(); - let mut rng = StdRng::seed_from_u64(70415); - let mut new_party = |platform: &mut TempPlatform| { - let (identity, signer, key) = - setup_identity(platform, rng.gen(), dash_to_credits!(1.0)); - Party { - identity, - signer, - key, - nonce: 2, - } - }; - let seller = new_party(&mut platform); - let buyer = new_party(&mut platform); - let second = new_party(&mut platform); - let members = [(seller.id(), 1), (second.id(), 1)].into(); - - let (contract, token_id) = create_token_contract_with_owner_identity( - &mut platform, - seller.id(), - Some(|token_configuration: &mut TokenConfiguration| { + let (market, second) = market_with_member( + |token_configuration: &mut TokenConfiguration| { make_token_tradeable_and_freezable(token_configuration); token_configuration.set_destroy_frozen_funds_rules(ChangeControlRules::V0( ChangeControlRulesV0 { @@ -4032,32 +3916,11 @@ mod frozen { self_changing_admin_action_takers_allowed: false, }, )); - }), - None, - Some( - [( - 0, - Group::V0(GroupV0 { - members, - required_power: 2, - }), - )] - .into(), - ), - None, - platform_version, - ); - - ( - Market { - platform, - contract, - token_id, - seller, - buyer, }, - second, - ) + dash_to_credits!(1.0), + true, + ); + (market, second.expect("expected the group's second member")) } /// A group destroy reads the frozen identity's listings again at the signature that @@ -4073,17 +3936,18 @@ mod frozen { let platform_version = PlatformVersion::latest(); let limit = platform_version .system_limits - .max_frozen_listings_destroyed_per_transition as usize; + .max_frozen_listings_destroyed_per_transition + .expect("expected the limit at the latest version") as usize; let (mut market, mut second) = market_destroyed_by_a_group(); // Listings of 10, 20, ... base units, one more than a destroy takes, and 100 in the balance let amounts: Vec = (1..=limit as u64 + 1).map(|i| 10 * i).collect(); let given = amounts.iter().sum::() + 100; - market.give_buyer(given).await; + market.transfer_to_buyer(given).await; let mut listings = vec![]; for (time_ms, amount) in (1..).zip(&amounts) { listings.push(market.buyer_lists(*amount, time_ms).await); } - market.freeze_buyer().await; + market.freeze_the_buyer().await; // The seller proposes: the oldest `limit` listings and the balance, nothing destroyed yet let proposed = 100 + amounts[..limit].iter().sum::(); @@ -4108,6 +3972,27 @@ mod frozen { market.assert_executes(&propose, &at_time(1_000_000)); assert_eq!(market.supply(), BASE_SUPPLY); assert_eq!(market.escrow(listings[0]), Some(10)); + let action_id = TokenDestroyFrozenFundsTransition::calculate_action_id_with_fields( + market.token_id.as_bytes(), + market.seller.id().as_bytes(), + proposer_nonce, + market.buyer.id().as_bytes(), + ); + let proposal = market + .platform + .drive + .fetch_active_action_info(market.contract.id(), 0, action_id, None, platform_version) + .expect("expected the proposed group action"); + let GroupActionEvent::TokenEvent(TokenEvent::DestroyFrozenFunds(frozen, recorded, _)) = + proposal.event() + else { + panic!("expected a destroy frozen funds proposal: {proposal:?}"); + }; + assert_eq!(*frozen, market.buyer.id()); + assert_eq!( + *recorded, proposed, + "the proposal records what it would destroy" + ); // The frozen identity cancels its oldest listing, whose escrow returns to its balance let cancel = market.cancel(listings[0], true).await; @@ -4115,12 +4000,6 @@ mod frozen { assert_eq!(market.token_balance(market.buyer.id()), Some(110)); // The second member completes the destroy - let action_id = TokenDestroyFrozenFundsTransition::calculate_action_id_with_fields( - market.token_id.as_bytes(), - market.seller.id().as_bytes(), - proposer_nonce, - market.buyer.id().as_bytes(), - ); let second_nonce = second.take_nonce(); let complete = BatchTransition::new_token_destroy_frozen_funds_transition( market.token_id, @@ -4158,7 +4037,7 @@ mod frozen { ); assert_eq!(market.listing(*order_id), None); } - assert!(given > proposed, "the destroy grew with the cancel"); + assert!(given > *recorded, "the destroy grew past what was proposed"); assert_eq!(market.supply(), BASE_SUPPLY - given); market.assert_supply_balanced(); let records = market.history("destroyFrozenFunds"); @@ -4177,10 +4056,10 @@ mod frozen { #[tokio::test] async fn should_leave_a_frozen_identitys_offers() { let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); - market.give_buyer(100).await; + market.transfer_to_buyer(100).await; let (place, order_id) = market.place_bid(1_000, PRICE).await; market.assert_executes(&place, &BlockInfo::default()); - market.freeze_buyer().await; + market.freeze_the_buyer().await; market.destroy_buyers_frozen_funds().await; diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs index 45ef423b6c1..4eb3e2fc3d0 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/batched_group_drain.rs @@ -25,8 +25,8 @@ //! that jointly empty a group see each other and the group is removed. The cases //! that specify the drain run at the latest version; the phantom generation 1 //! leaves (protocol version 14 and earlier) stays characterized as it actually -//! is, the inverse shape fails loud, and the sequential controls drain -//! correctly. They specify one hazard raising the cap would have had to fix, +//! is, the inverse shapes run at both generations and must refuse loudly or +//! apply correctly, and the sequential controls drain correctly. They specify one hazard raising the cap would have had to fix, //! not the whole shape: a real multi-transition batch would also fold several //! writes to the same identity-contract nonce key into one batch. //! @@ -990,8 +990,8 @@ fn observe( } } -/// Apply `operations` without panicking on a refusal — a refusal is a result -/// here, not a harness failure — and observe the state either way. +/// Apply `operations` at `at` without panicking on a refusal — a refusal is a +/// result here, not a harness failure — and observe the state either way. fn apply_and_observe( drive: &Drive, contract: &DataContract, @@ -999,16 +999,10 @@ fn apply_and_observe( operations: Vec, arrival: Identifier, departure: Identifier, + at: &PlatformVersion, ) -> (Result<(), Error>, Observation) { let applied = drive - .apply_drive_operations( - operations, - true, - &BlockInfo::default(), - None, - platform_version(), - None, - ) + .apply_drive_operations(operations, true, &BlockInfo::default(), None, at, None) .map(|_| ()); let observation = observe(drive, contract, axis, arrival, departure); (applied, observation) @@ -1059,6 +1053,16 @@ fn assert_refusal_is_loud(error: &Error, consistency_verification: bool, label: } } +/// The batch generations the refill cases run at: generation 1 (protocol +/// version 14, which builds each operation blind to the batch) and the latest +/// (which builds each document operation against the batch's earlier ones). +fn batch_generations() -> [&'static PlatformVersion; 2] { + [ + PlatformVersion::get(14).expect("expected protocol version 14"), + platform_version(), + ] +} + /// The fixture for both refill cases, on whichever batching configuration is /// under test. fn setup_g_one_h_one( @@ -1087,7 +1091,8 @@ fn setup_g_one_h_one( /// storage. /// /// The only two acceptable outcomes are a loud refusal and a fully correct -/// apply. Both operation orders and both batching configurations are swept, +/// apply. Both batch generations, both operation orders and both batching +/// configurations are swept, /// because Drive's own pre-flight consistency check and grovedb's applier /// reject a malformed batch at different places and only the second is what a /// shipped node relies on. @@ -1096,28 +1101,30 @@ fn deleting_a_groups_only_document_and_creating_another_in_one_batch_never_lands let mut report = String::new(); let mut bad = Vec::new(); - for axis in AXES { - for delete_first in [true, false] { - for consistency_verification in [true, false] { - let (drive, contract, departing) = - setup_g_one_h_one(axis, consistency_verification); - let arrival = build_doc(&contract, axis.doctype, axis.property, G, 40, 99); - assert_ne!( - arrival.id(), - departing.id(), - "the replacement must be a genuinely different document" - ); - - let delete = delete_op(&contract, axis.doctype, departing.id()); - let add = add_op(&contract, axis.doctype, &arrival); - let operations = if delete_first { - vec![delete, add] - } else { - vec![add, delete] - }; + for at in batch_generations() { + for axis in AXES { + for delete_first in [true, false] { + for consistency_verification in [true, false] { + let (drive, contract, departing) = + setup_g_one_h_one(axis, consistency_verification); + let arrival = build_doc(&contract, axis.doctype, axis.property, G, 40, 99); + assert_ne!( + arrival.id(), + departing.id(), + "the replacement must be a genuinely different document" + ); + + let delete = delete_op(&contract, axis.doctype, departing.id()); + let add = add_op(&contract, axis.doctype, &arrival); + let operations = if delete_first { + vec![delete, add] + } else { + vec![add, delete] + }; - let label = format!( - "{:?} axis / {} / consistency_verification={consistency_verification}", + let label = format!( + "protocol version {} / {:?} axis / {} / consistency_verification={consistency_verification}", + at.protocol_version, axis.ranked, if delete_first { "delete-then-create" @@ -1126,37 +1133,39 @@ fn deleting_a_groups_only_document_and_creating_another_in_one_batch_never_lands }, ); - let (applied, observed) = apply_and_observe( - &drive, - &contract, - axis, - operations, - arrival.id(), - departing.id(), - ); - report.push_str(&format!(" {label}\n → {applied:?} {observed:?}\n")); - - match &applied { - Err(error) => { - assert_refusal_is_loud(error, consistency_verification, &label); - assert_eq!( - observed, - untouched(axis), - "{label}: a refused batch must leave the state exactly as it was" - ); - } - Ok(()) => { - // G keeps exactly the arriving document, and the - // deleted one is gone from primary storage. - let correct = Observation { - g_in_ranking: Some(axis.expected_value(&[40])), - g_primary_present: true, - arrival_stored: true, - departure_stored: false, - grovedb_issues: 0, - }; - if observed != correct { - bad.push(label); + let (applied, observed) = apply_and_observe( + &drive, + &contract, + axis, + operations, + arrival.id(), + departing.id(), + at, + ); + report.push_str(&format!(" {label}\n → {applied:?} {observed:?}\n")); + + match &applied { + Err(error) => { + assert_refusal_is_loud(error, consistency_verification, &label); + assert_eq!( + observed, + untouched(axis), + "{label}: a refused batch must leave the state exactly as it was" + ); + } + Ok(()) => { + // G keeps exactly the arriving document, and the + // deleted one is gone from primary storage. + let correct = Observation { + g_in_ranking: Some(axis.expected_value(&[40])), + g_primary_present: true, + arrival_stored: true, + departure_stored: false, + grovedb_issues: 0, + }; + if observed != correct { + bad.push(label); + } } } } @@ -1185,25 +1194,27 @@ fn moving_one_document_out_of_a_group_while_creating_another_into_it_never_lands let mut report = String::new(); let mut bad = Vec::new(); - for axis in AXES { - for update_first in [true, false] { - for consistency_verification in [true, false] { - let (drive, contract, departing) = - setup_g_one_h_one(axis, consistency_verification); - - let moved = moved_to_h(&[departing]).remove(0); - let arrival = build_doc(&contract, axis.doctype, axis.property, G, 40, 99); - - let update = update_op(&contract, axis.doctype, &moved); - let add = add_op(&contract, axis.doctype, &arrival); - let operations = if update_first { - vec![update, add] - } else { - vec![add, update] - }; - - let label = format!( - "{:?} axis / {} / consistency_verification={consistency_verification}", + for at in batch_generations() { + for axis in AXES { + for update_first in [true, false] { + for consistency_verification in [true, false] { + let (drive, contract, departing) = + setup_g_one_h_one(axis, consistency_verification); + + let moved = moved_to_h(&[departing]).remove(0); + let arrival = build_doc(&contract, axis.doctype, axis.property, G, 40, 99); + + let update = update_op(&contract, axis.doctype, &moved); + let add = add_op(&contract, axis.doctype, &arrival); + let operations = if update_first { + vec![update, add] + } else { + vec![add, update] + }; + + let label = format!( + "protocol version {} / {:?} axis / {} / consistency_verification={consistency_verification}", + at.protocol_version, axis.ranked, if update_first { "update-out-then-create-in" @@ -1212,37 +1223,39 @@ fn moving_one_document_out_of_a_group_while_creating_another_into_it_never_lands }, ); - let (applied, observed) = apply_and_observe( - &drive, - &contract, - axis, - operations, - arrival.id(), - moved.id(), - ); - report.push_str(&format!(" {label}\n → {applied:?} {observed:?}\n")); - - match &applied { - Err(error) => { - assert_refusal_is_loud(error, consistency_verification, &label); - assert_eq!( - observed, - untouched(axis), - "{label}: a refused batch must leave the state exactly as it was" - ); - } - Ok(()) => { - // G keeps exactly the arriving document; the moved one - // stays in primary storage (it was updated, not deleted). - let correct = Observation { - g_in_ranking: Some(axis.expected_value(&[40])), - g_primary_present: true, - arrival_stored: true, - departure_stored: true, - grovedb_issues: 0, - }; - if observed != correct { - bad.push(label); + let (applied, observed) = apply_and_observe( + &drive, + &contract, + axis, + operations, + arrival.id(), + moved.id(), + at, + ); + report.push_str(&format!(" {label}\n → {applied:?} {observed:?}\n")); + + match &applied { + Err(error) => { + assert_refusal_is_loud(error, consistency_verification, &label); + assert_eq!( + observed, + untouched(axis), + "{label}: a refused batch must leave the state exactly as it was" + ); + } + Ok(()) => { + // G keeps exactly the arriving document; the moved one + // stays in primary storage (it was updated, not deleted). + let correct = Observation { + g_in_ranking: Some(axis.expected_value(&[40])), + g_primary_present: true, + arrival_stored: true, + departure_stored: true, + grovedb_issues: 0, + }; + if observed != correct { + bad.push(label); + } } } } @@ -1261,9 +1274,8 @@ fn moving_one_document_out_of_a_group_while_creating_another_into_it_never_lands // --------------------------------------------------------------------------- /// The control for the move path: the same two moves applied one at a time -/// drain `G` cleanly. Without it, a failure of the ignored batched-move case -/// would be ambiguous between "batching is the problem" and "moves are the -/// problem". +/// drain `G` cleanly. Without it, a failure of the batched-move case would be +/// ambiguous between "batching is the problem" and "moves are the problem". #[test] fn moving_the_same_two_documents_to_another_group_one_at_a_time_drains_it() { for axis in AXES { @@ -1284,7 +1296,7 @@ fn moving_the_same_two_documents_to_another_group_one_at_a_time_drains_it() { } } -/// The control that makes the ignored cases above mean something: the same two +/// The control that makes the batched cases above mean something: the same two /// deletes in two separate `apply_drive_operations` calls drain `G` cleanly /// from both the primary and the secondary. If this ever fails, the defect is /// not batch-specific and is much larger than the one documented here. diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/summable_off_count_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/summable_off_count_index_e2e_tests.rs index b0dc7b17751..8a91d015978 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/summable_off_count_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/summable_off_count_index_e2e_tests.rs @@ -58,6 +58,7 @@ use crate::util::object_size_info::{ }; use crate::util::storage_flags::StorageFlags; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use dpp::version::PlatformVersion; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -1103,9 +1104,9 @@ fn should_refuse_a_batch_writing_two_likes_of_a_type_keeping_counters() { assert!(matches!(counter, Element::SumItem(1, _)), "got {counter:?}"); } -/// Both generations of the batch conversion through its dispatcher: v1 -/// (protocol version 14) refuses a batch writing two likes, while v0, every -/// earlier version's, converts it as it always has. +/// The generations of the batch conversion through its dispatcher: v1 +/// (protocol version 14) and v2 (15) refuse a batch writing two likes, while +/// v0, every earlier version's, converts it as it always has. #[test] fn should_refuse_two_likes_only_from_the_convert_generation_that_checks_counter_moves() { let (drive, contract) = setup(true); @@ -1121,19 +1122,29 @@ fn should_refuse_two_likes_only_from_the_convert_generation_that_checks_counter_ .collect::>() }; let latest = platform_version(); - let refused = drive.convert_drive_operations_to_grove_operations( - operations(), - &BlockInfo::default(), - None, + for refusing in [ + PlatformVersion::get(14).expect("expected protocol version 14"), latest, - ); - assert!( - matches!( - refused, - Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) - ), - "v1 refuses the batch" - ); + ] { + let refused = drive.convert_drive_operations_to_grove_operations( + operations(), + &BlockInfo::default(), + None, + refusing, + ); + assert!( + matches!( + refused, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "generation {} refuses the batch", + refusing + .drive + .methods + .batch_operations + .convert_drive_operations_to_grove_operations + ); + } let mut first_generation = latest.clone(); first_generation .drive diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs index b197694b69f..574016a6fc2 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs @@ -13,9 +13,20 @@ impl Drive { /// The open listings of `owner_id` for `token_id` that a `TokenDestroyFrozenFunds` destroys /// with the frozen balance: the oldest first, at most /// `max_frozen_listings_destroyed_per_transition`, read through the `ask` type's `byOwner` - /// index, with the processing cost of the read billed at `epoch`. `VersionNotActive` where - /// `fetch_frozen_listings` is `None` (before protocol version 15): a destroy there reads no - /// listings and does not call this. + /// index. `VersionNotActive` where `fetch_frozen_listings` is `None` (before protocol + /// version 15): a destroy there reads no listings and does not call this. + /// + /// # Parameters + /// - `token_id`: the token whose listings are read. + /// - `owner_id`: the frozen identity, the listings' maker. + /// - `epoch`: the epoch the read's processing cost is priced at. + /// - `transaction`: the transaction to read in. + /// - `platform_version`: selects the generation (`order_escrow.fetch_frozen_listings`) and + /// the limit. + /// + /// # Returns + /// The listings, oldest first, and the processing fee of the read in credits, which this + /// does not charge: the caller adds it to the transition's fee. pub fn fetch_frozen_token_listings( &self, token_id: Identifier, diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs index f802a721519..236293bf897 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs @@ -1,5 +1,6 @@ use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; use crate::query::ordering::OrderClause; use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; @@ -56,7 +57,10 @@ impl Drive { limit: Some( platform_version .system_limits - .max_frozen_listings_destroyed_per_transition, + .max_frozen_listings_destroyed_per_transition + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "the frozen listings are read only where their limit is set", + )))?, ), order_by: IndexMap::from([( "$createdAt".to_string(), diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs index 2281a566319..f83b55b8132 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs @@ -24,12 +24,12 @@ impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransition .convert_to_high_level_operations .token_destroy_frozen_funds_transition { - 0 => self.destroy_frozen_funds_operations_v0(owner_id), + 0 => self.into_high_level_batch_drive_operations_v0(owner_id), // From protocol version 15: also destroys what the frozen identity's open listings // hold in escrow, and deletes their `ask` documents. - 1 => self.destroy_frozen_funds_and_listings_operations_v1(owner_id, platform_version), + 1 => self.into_high_level_batch_drive_operations_v1(owner_id), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "TokenDestroyFrozenFundsTransitionAction::into_high_level_document_drive_operations" + method: "TokenDestroyFrozenFundsTransitionAction::into_high_level_batch_drive_operations" .to_string(), known_versions: vec![0, 1], received: version, @@ -51,13 +51,14 @@ mod tests { use crate::util::batch::IdentityOperationType; use crate::util::batch::DocumentOperationType; use crate::util::batch::DriveOperation::DocumentOperation; + use crate::util::object_size_info::DocumentTypeInfo; use dpp::balances::credits::TokenAmount; use dpp::tokens::token_order_side::TokenOrderSide; use crate::drive::contract::DataContractFetchInfo; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{ TokenBaseTransitionAction, TokenBaseTransitionActionV0, }; - use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionActionV0; + use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{FrozenListings, TokenDestroyFrozenFundsTransitionActionV0}; use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; use dpp::block::block_info::BlockInfo; use dpp::data_contract::group::v0::GroupV0; @@ -114,7 +115,10 @@ mod tests { let contract = Arc::new(DataContractFetchInfo::dpns_contract_fixture( protocol_version, )); - let token_market_contract = (!escrowed_listings.is_empty()).then(|| Arc::clone(&contract)); + let frozen_listings = (!escrowed_listings.is_empty()).then(|| FrozenListings { + listings: escrowed_listings, + token_market_contract: Arc::clone(&contract), + }); TokenDestroyFrozenFundsTransitionActionV0 { base: TokenBaseTransitionAction::V0(TokenBaseTransitionActionV0 { token_id: Identifier::from(TOKEN_ID), @@ -126,8 +130,7 @@ mod tests { }), frozen_identity_id: Identifier::from(FROZEN), amount: AMOUNT, - escrowed_listings, - token_market_contract, + frozen_listings, public_note: Some("note".to_string()), } .into() @@ -168,6 +171,34 @@ mod tests { ) } + /// The holding destroy of the balance and the escrow of listings 1 and 2, lowering the + /// supply by the balance and their 500. + fn is_holding_destroy(operation: &DriveOperation) -> bool { + matches!( + operation, + TokenOperation(TokenOperationType::TokenDestroyFrozenHolding { + balance_amount: AMOUNT, + order_ids, + destroyed_amount, + .. + }) if *order_ids == vec![Identifier::from([1; 32]), Identifier::from([2; 32])] + && *destroyed_amount == AMOUNT + 500 + ) + } + + /// The deletion of listing `id`'s `ask` document. + fn is_delete_of(operation: &DriveOperation, id: u8) -> bool { + matches!( + operation, + DocumentOperation(DocumentOperationType::DeleteDocument { + document_id, + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr(name), + .. + }) if *document_id == Identifier::from([id; 32]) + && *name == TokenOrderSide::Ask.document_type_name() + ) + } + fn is_history_of(operation: &DriveOperation, destroyed: TokenAmount) -> bool { matches!( operation, @@ -234,36 +265,31 @@ mod tests { } /// Generation 1, which protocol version 15 selects: without listings, generation 0's - /// operations; with listings, one holding destroy of the balance and every listing's escrow, - /// one `DeleteDocuments` of their `ask` documents and the history of everything destroyed. + /// operations, in a group too; with listings, one holding destroy of the balance and every + /// listing's escrow, a `DeleteDocument` per listing and the history of everything destroyed. #[test] fn should_destroy_the_listings_with_the_balance_at_protocol_version_15() { let latest = PlatformVersion::latest(); - let operations = convert(action(None, true, vec![]), latest); - assert_eq!(operations.len(), 3, "{operations:?}"); - assert!(is_burn_of_the_balance(&operations[1])); - assert!(is_history_of(&operations[2], AMOUNT)); + for (group, perform_action) in [ + (None, true), + (Some(in_group(true)), false), + (Some(in_group(false)), true), + ] { + let at_15 = convert(action(group.clone(), perform_action, vec![]), latest); + let at_14 = convert(action(group, perform_action, vec![]), pv14()); + assert_eq!(format!("{at_15:?}"), format!("{at_14:?}")); + } let operations = convert( action(None, true, vec![listing(1, 300), listing(2, 200)]), latest, ); - assert_eq!(operations.len(), 4, "{operations:?}"); + assert_eq!(operations.len(), 5, "{operations:?}"); assert!(is_nonce(&operations[0])); - assert!(matches!( - &operations[1], - TokenOperation(TokenOperationType::TokenDestroyFrozenHolding { - balance_amount: AMOUNT, - listings, - .. - }) if *listings == vec![(Identifier::from([1; 32]), 300), (Identifier::from([2; 32]), 200)] - )); - assert!(matches!( - &operations[2], - DocumentOperation(DocumentOperationType::DeleteDocuments { document_ids, .. }) - if *document_ids == vec![Identifier::from([1; 32]), Identifier::from([2; 32])] - )); - assert!(is_history_of(&operations[3], AMOUNT + 500)); + assert!(is_holding_destroy(&operations[1])); + assert!(is_delete_of(&operations[2], 1)); + assert!(is_delete_of(&operations[3], 2)); + assert!(is_history_of(&operations[4], AMOUNT + 500)); } /// Generation 1 in a group, with listings: a proposal that does not perform yet records @@ -295,7 +321,7 @@ mod tests { )); let operations = convert(action(Some(in_group(false)), true, listings()), latest); - assert_eq!(operations.len(), 5, "{operations:?}"); + assert_eq!(operations.len(), 6, "{operations:?}"); assert!(is_nonce(&operations[0])); assert!(matches!( &operations[1], @@ -305,33 +331,28 @@ mod tests { .. }) )); - assert!(matches!( - &operations[2], - TokenOperation(TokenOperationType::TokenDestroyFrozenHolding { - balance_amount: AMOUNT, - listings, - .. - }) if *listings == vec![(Identifier::from([1; 32]), 300), (Identifier::from([2; 32]), 200)] - )); - assert!(matches!( - &operations[3], - DocumentOperation(DocumentOperationType::DeleteDocuments { document_ids, .. }) - if *document_ids == vec![Identifier::from([1; 32]), Identifier::from([2; 32])] - )); - assert!(is_history_of(&operations[4], AMOUNT + 500)); + assert!(is_holding_destroy(&operations[2])); + assert!(is_delete_of(&operations[3], 1)); + assert!(is_delete_of(&operations[4], 2)); + assert!(is_history_of(&operations[5], AMOUNT + 500)); } /// The history the converter emits is the history the shared builders reconstruct /// (`associated_token_event`, `build_historical_document`): the frozen balance alone at - /// protocol version 14, also when the action carries listings, and the balance with the + /// protocol version 14, whose transformer reads no listings, and the balance with the /// listings' escrow at 15. #[test] fn should_emit_the_history_the_shared_builders_reconstruct() { let block_info = BlockInfo::default(); - for (platform_version, destroyed) in - [(pv14(), AMOUNT), (PlatformVersion::latest(), AMOUNT + 500)] - { - let action = action(None, true, vec![listing(1, 300), listing(2, 200)]); + for (platform_version, listings, destroyed) in [ + (pv14(), vec![], AMOUNT), + ( + PlatformVersion::latest(), + vec![listing(1, 300), listing(2, 200)], + AMOUNT + 500, + ), + ] { + let action = action(None, true, listings); let token_action = TokenTransitionAction::DestroyFrozenFundsAction(action.clone()); let operations = convert(action, platform_version); let Some(TokenOperation(TokenOperationType::TokenHistory { event, .. })) = @@ -342,7 +363,7 @@ mod tests { assert!(is_history_of(operations.last().unwrap(), destroyed)); let reconstructed = token_action - .associated_token_event(platform_version) + .associated_token_event() .expect("expected the event"); assert_eq!(&reconstructed, event); diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v0/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v0/mod.rs index 7768d3a36d5..12cae5a7ccb 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v0/mod.rs @@ -15,7 +15,7 @@ impl TokenDestroyFrozenFundsTransitionAction { /// Generation 0 (protocol version 14 and earlier): bumps the signer's nonce, records the /// signature of a group action, and when the action is performed burns the frozen balance /// and records the destroy in the token's history. - pub(super) fn destroy_frozen_funds_operations_v0<'b>( + pub(super) fn into_high_level_batch_drive_operations_v0<'b>( self, owner_id: Identifier, ) -> Result>, Error> { diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v1/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v1/mod.rs index bf81650be94..999b40935c6 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v1/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/v1/mod.rs @@ -2,15 +2,12 @@ use dpp::group::action_event::GroupActionEvent; use dpp::group::group_action::GroupAction; use dpp::group::group_action::v0::GroupActionV0; use dpp::group::GroupStateTransitionResolvedInfo; -use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; use dpp::tokens::token_event::TokenEvent; use dpp::tokens::token_order_side::TokenOrderSide; -use platform_version::version::PlatformVersion; -use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; -use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{FrozenListings, TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; use crate::util::batch::drive_op_batch::{GroupOperationType, TokenOperationType}; use crate::util::batch::DriveOperation::{DocumentOperation, GroupOperation, IdentityOperation, TokenOperation}; @@ -18,28 +15,29 @@ use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; impl TokenDestroyFrozenFundsTransitionAction { /// Generation 1 (protocol version 15): generation 0's operations, and with them the - /// destruction of the frozen identity's open listings the transformer read. One - /// `TokenDestroyFrozenHolding` burns the balance, empties each listing's escrow and lowers - /// the supply by everything destroyed; one `DeleteDocuments` deletes the listings' `ask` - /// documents, each built against the ones before it so index trees they share are removed - /// and refunded; the history and group-action events record everything destroyed. Without - /// listings the operations are generation 0's. - pub(super) fn destroy_frozen_funds_and_listings_operations_v1<'b>( + /// destruction of the frozen identity's open listings the transformer read. Without listings + /// it is generation 0. With them, one `TokenDestroyFrozenHolding` burns the balance, empties + /// each listing's escrow and lowers the supply by everything destroyed; a `DeleteDocument` + /// per listing deletes its `ask` document, each built against the batch's operations before + /// it (`apply_drive_operations` 2), so index trees the listings share are removed and + /// refunded; the history and group-action events record everything destroyed. + pub(super) fn into_high_level_batch_drive_operations_v1<'b>( self, owner_id: Identifier, - platform_version: &PlatformVersion, ) -> Result>, Error> { + let Some(FrozenListings { + listings, + token_market_contract, + }) = self.frozen_listings().cloned() + else { + return self.into_high_level_batch_drive_operations_v0(owner_id); + }; + let data_contract_id = self.base().data_contract_id(); let identity_contract_nonce = self.base().identity_contract_nonce(); - // What the destroy needs of each listing: its id and the tokens it holds. - let listings: Vec<(Identifier, TokenAmount)> = self - .escrowed_listings() - .iter() - .map(|listing| (listing.id, listing.remaining)) - .collect(); - let destroyed_amount = self.destroyed_amount(platform_version)?; + let destroyed_amount = self.destroyed_amount()?; let mut ops = vec![IdentityOperation( IdentityOperationType::UpdateIdentityContractNonce { @@ -86,30 +84,21 @@ impl TokenDestroyFrozenFundsTransitionAction { } if self.base().perform_action() { - if listings.is_empty() { - ops.push(TokenOperation(TokenOperationType::TokenBurn { + ops.push(TokenOperation( + TokenOperationType::TokenDestroyFrozenHolding { token_id: self.token_id(), - identity_balance_holder_id: self.frozen_identity_id(), - burn_amount: self.amount(), - })); - } else { - let token_market_contract = self.token_market_contract().cloned().ok_or( - Error::Drive(DriveError::CorruptedCodeExecution( - "listings to destroy come with the token market contract", - )), - )?; - let document_ids = listings.iter().map(|(id, _)| *id).collect(); - ops.push(TokenOperation( - TokenOperationType::TokenDestroyFrozenHolding { - token_id: self.token_id(), - holder_id: self.frozen_identity_id(), - balance_amount: self.amount(), - listings, - }, - )); - ops.push(DocumentOperation(DocumentOperationType::DeleteDocuments { - document_ids, - contract_info: DataContractInfo::DataContractFetchInfo(token_market_contract), + holder_id: self.frozen_identity_id(), + balance_amount: self.amount(), + order_ids: listings.iter().map(|listing| listing.id).collect(), + destroyed_amount, + }, + )); + for listing in &listings { + ops.push(DocumentOperation(DocumentOperationType::DeleteDocument { + document_id: listing.id, + contract_info: DataContractInfo::DataContractFetchInfo( + token_market_contract.clone(), + ), document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( TokenOrderSide::Ask.document_type_name(), ), diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs index 82470290c33..91a18267a39 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs @@ -47,9 +47,7 @@ impl DriveHighLevelBatchOperationConverter for TokenTransitionAction { | TokenTransitionAction::FillAskAction(_) | TokenTransitionAction::CancelOrderAction(_) | TokenTransitionAction::PlaceBidAction(_) - | TokenTransitionAction::FillBidAction(_) => { - self.token_history_operation(owner_id, platform_version)? - } + | TokenTransitionAction::FillBidAction(_) => self.token_history_operation(owner_id)?, _ => None, }; match self { @@ -152,7 +150,6 @@ impl TokenTransitionAction { fn token_history_operation<'b>( &self, owner_id: Identifier, - platform_version: &PlatformVersion, ) -> Result>, Error> { if !self.keeps_history()? { return Ok(None); @@ -162,17 +159,15 @@ impl TokenTransitionAction { token_id: self.base().token_id(), owner_id, nonce: self.base().identity_contract_nonce(), - event: self.associated_token_event(platform_version)?, + event: self.associated_token_event()?, }, ))) } - /// Gets the associated token event for the transition action: the event its conversion at - /// `platform_version` records in the token history - pub fn associated_token_event( - &self, - platform_version: &PlatformVersion, - ) -> Result { + /// Gets the associated token event for the transition action: the event its conversion + /// records in the token history. Fails only for a destroy whose destroyed amount + /// overflows (`destroyed_amount`). + pub fn associated_token_event(&self) -> Result { Ok(match self { TokenTransitionAction::BurnAction(burn_action) => TokenEvent::Burn( burn_action.burn_amount(), @@ -217,7 +212,7 @@ impl TokenTransitionAction { TokenTransitionAction::DestroyFrozenFundsAction(destroy_action) => { TokenEvent::DestroyFrozenFunds( destroy_action.frozen_identity_id(), - destroy_action.destroyed_amount(platform_version)?, + destroy_action.destroyed_amount()?, destroy_action.public_note().cloned(), ) } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs index eb2193c9770..448c9f9ba52 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs @@ -285,7 +285,7 @@ impl TokenTransitionAction { block_info: &BlockInfo, platform_version: &PlatformVersion, ) -> Result { - self.associated_token_event(platform_version)? + self.associated_token_event()? .build_historical_document_owned( token_id, owner_id, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/mod.rs deleted file mode 100644 index 0633c24fc6c..00000000000 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/mod.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::error::drive::DriveError; -use crate::error::Error; -use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionAction; -use dpp::balances::credits::TokenAmount; -use platform_version::version::PlatformVersion; - -mod v0; -mod v1; - -impl TokenDestroyFrozenFundsTransitionAction { - /// What the transition's history and group-action events record as destroyed, as the - /// converter generation `platform_version` selects - /// (`token_destroy_frozen_funds_transition`): the frozen balance alone in generation 0, and - /// the balance with what the listings hold in escrow from generation 1 (protocol version - /// 15). The converter and the shared builders (`associated_token_event`, - /// `build_historical_document`) all read it, so they record the same amount. - pub fn destroyed_amount( - &self, - platform_version: &PlatformVersion, - ) -> Result { - match platform_version - .drive - .methods - .state_transitions - .convert_to_high_level_operations - .token_destroy_frozen_funds_transition - { - 0 => Ok(self.destroyed_amount_v0()), - 1 => self.destroyed_amount_v1(), - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "TokenDestroyFrozenFundsTransitionAction::destroyed_amount".to_string(), - known_versions: vec![0, 1], - received: version, - })), - } - } -} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v0/mod.rs deleted file mode 100644 index 207998ade4d..00000000000 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v0/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; -use dpp::balances::credits::TokenAmount; - -impl TokenDestroyFrozenFundsTransitionAction { - /// Generation 0 (protocol version 14 and earlier): the frozen balance alone. - pub(super) fn destroyed_amount_v0(&self) -> TokenAmount { - self.amount() - } -} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v1/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v1/mod.rs deleted file mode 100644 index a90158fbb05..00000000000 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/destroyed_amount/v1/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::error::drive::DriveError; -use crate::error::Error; -use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::{TokenDestroyFrozenFundsTransitionAction, TokenDestroyFrozenFundsTransitionActionAccessorsV0}; -use dpp::balances::credits::TokenAmount; - -impl TokenDestroyFrozenFundsTransitionAction { - /// Generation 1 (protocol version 15): the frozen balance and what its listings hold in - /// escrow, summed with checked arithmetic. - pub(super) fn destroyed_amount_v1(&self) -> Result { - self.total_destroyed() - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "a frozen holding is more tokens than a u64 holds", - ))) - } -} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs index 2f7251c6c90..b72507bc256 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/mod.rs @@ -1,17 +1,15 @@ -use crate::drive::contract::DataContractFetchInfo; use derive_more::From; use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; -use dpp::tokens::token_order::TokenOrder; -use std::sync::Arc; -mod destroyed_amount; /// transformer module for token destroy_frozen_funds transition action pub mod transformer; mod v0; pub use v0::*; // re-export the v0 module items (including TokenIssuanceTransitionActionV0) +use crate::error::drive::DriveError; +use crate::error::Error; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; /// Token destroy_frozen_funds transition action @@ -21,6 +19,27 @@ pub enum TokenDestroyFrozenFundsTransitionAction { V0(TokenDestroyFrozenFundsTransitionActionV0), } +impl TokenDestroyFrozenFundsTransitionAction { + /// Everything the transition destroys, which its history and group-action events record: + /// the frozen balance and what its listings hold in escrow. Before protocol version 15 the + /// transformer reads no listings, so it is the balance alone. + /// + /// # Returns + /// + /// The balance plus every listing's `remaining`, summed with checked arithmetic, or + /// `CorruptedCodeExecution` if that is more tokens than a `u64` holds. + pub fn destroyed_amount(&self) -> Result { + self.escrowed_listings() + .iter() + .try_fold(self.amount(), |total, listing| { + total.checked_add(listing.remaining) + }) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a frozen holding is more tokens than a u64 holds", + ))) + } +} + impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 for TokenDestroyFrozenFundsTransitionAction { @@ -60,15 +79,9 @@ impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 } } - fn escrowed_listings(&self) -> &[TokenOrder] { - match self { - TokenDestroyFrozenFundsTransitionAction::V0(v0) => &v0.escrowed_listings, - } - } - - fn token_market_contract(&self) -> Option<&Arc> { + fn frozen_listings(&self) -> Option<&FrozenListings> { match self { - TokenDestroyFrozenFundsTransitionAction::V0(v0) => v0.token_market_contract.as_ref(), + TokenDestroyFrozenFundsTransitionAction::V0(v0) => v0.frozen_listings.as_ref(), } } @@ -141,8 +154,7 @@ mod tests { frozen_identity_id: Identifier::from(TEST_FROZEN_ID), amount: TEST_AMOUNT, public_note: Some("destroyed".to_string()), - escrowed_listings: vec![], - token_market_contract: None, + frozen_listings: None, } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/mod.rs index 74beed2f45c..1f5d8af181a 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/mod.rs @@ -16,16 +16,23 @@ pub struct TokenDestroyFrozenFundsTransitionActionV0 { pub frozen_identity_id: Identifier, /// The amount that will be burned pub amount: TokenAmount, - /// The frozen identity's open listings destroyed with its balance, oldest first (protocol - /// version 15; empty before) - pub escrowed_listings: Vec, - /// The token market contract, whose `ask` documents record the listings; read only when - /// there are listings to destroy - pub token_market_contract: Option>, + /// The frozen identity's open listings destroyed with its balance (protocol version 15); + /// `None` when there are none to destroy, and always before + pub frozen_listings: Option, /// A public note pub public_note: Option, } +/// A frozen identity's open listings a `TokenDestroyFrozenFunds` destroys with its balance +/// (protocol version 15), never empty +#[derive(Debug, Clone)] +pub struct FrozenListings { + /// The listings, oldest first + pub listings: Vec, + /// The token market contract, whose `ask` documents record the listings + pub token_market_contract: Arc, +} + /// Accessors for `TokenIssuanceTransitionActionV0` pub trait TokenDestroyFrozenFundsTransitionActionAccessorsV0 { /// Returns a reference to the base token transition action @@ -46,19 +53,14 @@ pub trait TokenDestroyFrozenFundsTransitionActionAccessorsV0 { /// Sets the amount of tokens that the identity had and will be burned fn set_amount(&mut self, amount: TokenAmount); - /// The frozen identity's open listings destroyed with its balance - fn escrowed_listings(&self) -> &[TokenOrder]; - - /// The token market contract, when there are listings to destroy - fn token_market_contract(&self) -> Option<&Arc>; + /// The frozen identity's open listings destroyed with its balance, with the token market + /// contract their `ask` documents are deleted from; `None` when there are none + fn frozen_listings(&self) -> Option<&FrozenListings>; - /// Everything destroyed: the balance and what the listings hold in escrow - fn total_destroyed(&self) -> Option { - self.escrowed_listings() - .iter() - .try_fold(self.amount(), |total, listing| { - total.checked_add(listing.remaining) - }) + /// The frozen identity's open listings destroyed with its balance, oldest first + fn escrowed_listings(&self) -> &[TokenOrder] { + self.frozen_listings() + .map_or(&[][..], |frozen| frozen.listings.as_slice()) } /// Returns the token position in the contract @@ -123,12 +125,8 @@ impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 self.amount = amount; } - fn escrowed_listings(&self) -> &[TokenOrder] { - &self.escrowed_listings - } - - fn token_market_contract(&self) -> Option<&Arc> { - self.token_market_contract.as_ref() + fn frozen_listings(&self) -> Option<&FrozenListings> { + self.frozen_listings.as_ref() } fn public_note(&self) -> Option<&String> { @@ -177,8 +175,7 @@ mod tests { frozen_identity_id: frozen, amount, public_note: note.map(|s| s.to_string()), - escrowed_listings: vec![], - token_market_contract: None, + frozen_listings: None, } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs index 8ed9e9279d6..5a45f3f66a6 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_destroy_frozen_funds_transition_action/v0/transformer.rs @@ -6,10 +6,9 @@ use dpp::consensus::state::token::IdentityDoesNotHaveEnoughTokenBalanceError; use dpp::identifier::Identifier; use dpp::state_transition::batch_transition::token_destroy_frozen_funds_transition::v0::TokenDestroyFrozenFundsTransitionV0; use dpp::ProtocolError; -use dpp::tokens::token_order::TokenOrder; use crate::drive::contract::DataContractFetchInfo; -use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; -use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::v0::TokenDestroyFrozenFundsTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::v0::{FrozenListings, TokenDestroyFrozenFundsTransitionActionV0}; use dpp::fee::fee_result::FeeResult; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; @@ -149,8 +148,9 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } }; - let (escrowed_listings, token_market_contract) = read_frozen_listings( + let frozen_listings = read_frozen_listings( drive, + &base_action, base.token_id(), frozen_identity_id, block_info, @@ -165,8 +165,7 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { base: base_action, frozen_identity_id, amount: token_amount, - escrowed_listings, - token_market_contract, + frozen_listings, public_note: change_note.unwrap_or(public_note), } .into(), @@ -309,8 +308,9 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } }; - let (escrowed_listings, token_market_contract) = read_frozen_listings( + let frozen_listings = read_frozen_listings( drive, + &base_action, base.token_id(), *frozen_identity_id, block_info, @@ -325,8 +325,7 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { base: base_action, frozen_identity_id: *frozen_identity_id, amount: token_amount, - escrowed_listings, - token_market_contract, + frozen_listings, public_note: change_note.unwrap_or(public_note.clone()), } .into(), @@ -337,25 +336,29 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } } -/// The frozen identity's open listings a destroy takes with its balance and, when there are -/// any, the token market contract their `ask` documents are deleted from. From protocol version -/// 15 the listings are destroyed with the balance, as what they hold sits in escrow, out of the -/// balance's reach; earlier versions read none. The read is billed to `fee_result`. +/// The frozen identity's open listings a destroy takes with its balance, with the token market +/// contract their `ask` documents are deleted from; `None` when there are none. From protocol +/// version 15 the listings are destroyed with the balance, as what they hold sits in escrow, out +/// of the balance's reach; earlier versions read none. The read is billed to `fee_result`. /// -/// A group destroy reads them at every signature, and the one that completes it destroys what is -/// there then, as it does the balance: the amount the proposal recorded is not a bound. A frozen -/// identity can only cancel a listing, which moves its escrow into that balance, so a cancel -/// between the signatures can only grow the destroy, never shrink it; refusing a completion -/// whose amount changed would let the frozen identity block every group destroy with one cancel. +/// A group destroy reads them at the signature that proposes it, whose event records the amount, +/// and at the one that completes it, which destroys what is there then, as it does the balance: +/// the amount the proposal recorded is not a bound. A signature that does neither records and +/// destroys nothing, so it reads nothing. A frozen identity can only cancel a listing, which +/// moves its escrow into that balance, so a cancel between the signatures can only grow the +/// destroy, never shrink it; refusing a completion whose amount changed would let the frozen +/// identity block every group destroy with one cancel. +#[allow(clippy::too_many_arguments)] fn read_frozen_listings( drive: &Drive, + base_action: &TokenBaseTransitionAction, token_id: Identifier, frozen_identity_id: Identifier, block_info: &BlockInfo, fee_result: &mut FeeResult, transaction: TransactionArg, platform_version: &PlatformVersion, -) -> Result<(Vec, Option>), Error> { +) -> Result, Error> { // Before protocol version 15 there are no listings to destroy, and nothing is read: the // frozen balance alone is destroyed, as it always was. if platform_version @@ -366,7 +369,14 @@ fn read_frozen_listings( .fetch_frozen_listings .is_none() { - return Ok((vec![], None)); + return Ok(None); + } + let records_or_destroys = base_action.perform_action() + || base_action + .store_in_group() + .is_some_and(|group| group.action_is_proposer); + if !records_or_destroys { + return Ok(None); } let (listings, read_cost) = drive.fetch_frozen_token_listings( token_id, @@ -376,12 +386,13 @@ fn read_frozen_listings( platform_version, )?; fee_result.checked_add_assign(FeeResult::new_from_processing_fee(read_cost))?; - let token_market_contract = if listings.is_empty() { - None - } else { - Some(drive.token_market_contract_fetch_info(platform_version)?) - }; - Ok((listings, token_market_contract)) + if listings.is_empty() { + return Ok(None); + } + Ok(Some(FrozenListings { + listings, + token_market_contract: drive.token_market_contract_fetch_info(platform_version)?, + })) } #[cfg(test)] diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs index 1b6fe6bd476..35a447344fb 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs @@ -211,8 +211,7 @@ mod tests { frozen_identity_id: Identifier::new([8u8; 32]), amount: 9, public_note: None, - escrowed_listings: vec![], - token_market_contract: None, + frozen_listings: None, }, ), ); @@ -339,8 +338,7 @@ mod tests { frozen_identity_id: Identifier::new([8u8; 32]), amount: 1, public_note: None, - escrowed_listings: vec![], - token_market_contract: None, + frozen_listings: None, }, ), ), diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index 93640d47683..83195ccfad5 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -1821,8 +1821,7 @@ fn make_destroy_v0() -> TokenDestroyFrozenFundsTransitionActionV0 { frozen_identity_id: Identifier::from([0xCC; 32]), amount: 400, public_note: Some("destroy note".to_string()), - escrowed_listings: vec![], - token_market_contract: None, + frozen_listings: None, } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs index 26ab522494e..8be94e83d74 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/document.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/document.rs @@ -119,20 +119,6 @@ pub enum DocumentOperationType<'a> { /// Document type document_type_info: DocumentTypeInfo<'a>, }, - /// Deletes several documents of one document type in one conversion: each delete is built - /// against the deletes already built, so an index tree the documents share (the same indexed - /// value, the same `$createdAt` bucket) is seen emptied by the last of them and removed, and - /// whoever paid for it gets its storage back. Separate `DeleteDocument`s would each read the - /// others as still stored and leave the emptied tree behind. Used by the token marketplace's - /// destroy of a frozen identity's listings (protocol version 15). - DeleteDocuments { - /// The ids of the documents, deleted in this order - document_ids: Vec, - /// Data Contract info to potentially be resolved if needed - contract_info: DataContractInfo<'a>, - /// Document type - document_type_info: DocumentTypeInfo<'a>, - }, /// Deletes a document without consulting `canBeDeleted`, which rules what the document's /// own owner may do. Used for a deletion on behalf of the contract's moderators (the /// caller has checked that the document type sets `moderatorAbilities.delete`), for a @@ -315,25 +301,6 @@ impl DocumentOperationType<'_> { matches!(self, Self::AddDocument { .. }), ) } - Self::DeleteDocuments { - document_ids, - contract_info, - document_type_info, - } => { - let resolved = contract_info.clone().resolve( - drive, - block_info, - transaction, - &mut vec![], - platform_version, - )?; - let contract = resolved.as_ref(); - let document_type = document_type_info.clone().resolve(contract)?; - for _ in document_ids { - visit(contract, document_type, false)?; - } - Ok(()) - } Self::MultipleDocumentOperationsForSameContractDocumentType { document_operations, } => { @@ -508,27 +475,9 @@ impl DocumentOperationType<'_> { contract_info, document_type_info, } => { - Self::append_deletions( + Self::append_deletion( drive, - vec![document_id], - contract_info, - document_type_info, - false, - estimated_costs_only_with_layer_info, - block_info, - batch_operations, - transaction, - platform_version, - )?; - } - DocumentOperationType::DeleteDocuments { - document_ids, - contract_info, - document_type_info, - } => { - Self::append_deletions( - drive, - document_ids, + document_id, contract_info, document_type_info, false, @@ -544,9 +493,9 @@ impl DocumentOperationType<'_> { contract_info, document_type_info, } => { - Self::append_deletions( + Self::append_deletion( drive, - vec![document_id], + document_id, contract_info, document_type_info, true, @@ -729,12 +678,12 @@ impl DocumentOperationType<'_> { Ok(()) } - /// Appends the deletions of `document_ids`, each built against the batch's operations so - /// far; `force` skips the owner's `canBeDeleted` guard (`ForceDeleteDocument`). + /// Appends the deletion of `document_id`, built against the batch's operations so far; + /// `force` skips the owner's `canBeDeleted` guard (`ForceDeleteDocument`). #[allow(clippy::too_many_arguments)] - fn append_deletions( + fn append_deletion( drive: &Drive, - document_ids: Vec, + document_id: Identifier, contract_info: DataContractInfo, document_type_info: DocumentTypeInfo, force: bool, @@ -755,32 +704,30 @@ impl DocumentOperationType<'_> { )?; let contract = contract_resolved_info.as_ref(); let document_type = document_type_info.resolve(contract)?; - for document_id in document_ids { - let operations = if force { - drive.force_delete_document_for_contract_operations( - document_id, - contract, - document_type, - Some(&mut *batch_operations), - estimated_costs_only_with_layer_info, - block_info.time_ms, - transaction, - platform_version, - )? - } else { - drive.delete_document_for_contract_operations_without_ttl_drain( - document_id, - contract, - document_type, - Some(&mut *batch_operations), - estimated_costs_only_with_layer_info, - block_info.time_ms, - transaction, - platform_version, - )? - }; - batch_operations.extend(operations); - } + let operations = if force { + drive.force_delete_document_for_contract_operations( + document_id, + contract, + document_type, + Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + platform_version, + )? + } else { + drive.delete_document_for_contract_operations_without_ttl_drain( + document_id, + contract, + document_type, + Some(&mut *batch_operations), + estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + platform_version, + )? + }; + batch_operations.extend(operations); Ok(()) } @@ -1030,39 +977,6 @@ impl DocumentOperationType<'_> { platform_version, ) } - DocumentOperationType::DeleteDocuments { - document_ids, - contract_info, - document_type_info, - } => { - let mut drive_operations: Vec = vec![]; - let contract_resolved_info = contract_info.resolve( - drive, - block_info, - transaction, - &mut drive_operations, - platform_version, - )?; - let contract = contract_resolved_info.as_ref(); - let document_type = document_type_info.resolve(contract)?; - // Each delete sees the deletes before it as pending, so a tree they empty - // together is removed by the last of them. - for document_id in document_ids { - let mut operations = drive - .delete_document_for_contract_operations_without_ttl_drain( - document_id, - contract, - document_type, - Some(&mut drive_operations), - estimated_costs_only_with_layer_info, - block_info.time_ms, - transaction, - platform_version, - )?; - drive_operations.append(&mut operations); - } - Ok(drive_operations) - } DocumentOperationType::ForceDeleteDocument { document_id, contract_info, 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 index c063e46701c..914ec00e7bb 100644 --- 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 @@ -36,11 +36,12 @@ impl Drive { /// document's nonce update describes the root a level deeper than the document does, so /// the root of every document batch is now estimated at the nonce's depth. /// - /// Each operation is also built against the operations of its GroveDB batch built before it - /// ([`DriveOperation::append_low_level_drive_operations_after_ttl_drain`]): a document - /// deletion sees the batch's earlier writes as pending, so two deletions emptying an index - /// tree together remove it and refund it, and an insert into a tree an earlier deletion of - /// the batch empties keeps the tree. + /// Each document operation is also built against the operations of its GroveDB batch built + /// before it ([`DriveOperation::append_low_level_drive_operations_after_ttl_drain`]): a + /// document deletion sees the batch's earlier writes as pending, so two deletions emptying + /// an index tree together remove it and refund it, and an insert into a tree an earlier + /// deletion of the batch empties keeps the tree. Every other operation kind is built as + /// generation 1 builds it, without the batch. #[inline(always)] pub(crate) fn apply_drive_operations_v2( &self, @@ -123,9 +124,9 @@ impl Drive { let forfeited = separates_forfeited_operations && matches!(drive_op, DriveOperation::DocumentOperation(_)); - // Each operation is built against the operations of its GroveDB batch built before - // it, and appended to them: a document deletion sees the batch's other writes as - // pending. An estimate lowers each operation into layer descriptions of its own and + // Each document operation is built against the operations of its GroveDB batch built + // before it, and every operation is appended to them: a document deletion sees the + // batch's other writes as pending. An estimate lowers each operation into layer descriptions of its own and // merges them into the batch's, one description covering both of two of one layer (the // deeper depth, with the nodes GroveDB prices dearer there): the batch's estimate is // the same whichever order its operations come in. @@ -246,8 +247,6 @@ impl Drive { #[cfg(test)] mod tests { use crate::drive::Drive; - use crate::error::drive::DriveError; - use crate::error::Error; use crate::util::batch::drive_op_batch::{ PrefundedSpecializedBalanceOperationType, TokenOperationType, }; @@ -341,45 +340,6 @@ mod tests { ); } - /// From protocol version 15 the converter that returns a batch instead of applying it - /// refuses a batch writing one token key twice, as this generation does: a frozen holding - /// destroy naming one listing twice, and two destroys of one holding. - #[test] - fn should_refuse_repeated_token_writes_through_the_raw_converter() { - let latest = PlatformVersion::latest(); - let drive = setup_drive_with_initial_state_structure(Some(latest)); - let destroy = |listings: Vec<(Identifier, u64)>| { - DriveOperation::TokenOperation(TokenOperationType::TokenDestroyFrozenHolding { - token_id: Identifier::from([5; 32]), - holder_id: Identifier::from([6; 32]), - balance_amount: 10, - listings, - }) - }; - let listing = Identifier::from([7; 32]); - for (case, operations) in [ - ( - "one listing twice", - vec![destroy(vec![(listing, 1), (listing, 1)])], - ), - ("one holding twice", vec![destroy(vec![]), destroy(vec![])]), - ] { - let refused = drive.convert_drive_operations_to_grove_operations( - operations, - &BlockInfo::default(), - None, - latest, - ); - assert!( - matches!( - refused, - Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) - ), - "{case}: {refused:?}" - ); - } - } - mod batch_document_writes { use crate::drive::{Drive, RootTree}; use crate::util::batch::drive_op_batch::{ @@ -391,17 +351,17 @@ mod tests { DataContractInfo, DocumentAndContractInfo, DocumentTypeInfo, OwnedDocumentInfo, }; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::subtree_paths; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; - use dpp::document::{Document, DocumentV0}; + use dpp::document::Document; use dpp::identifier::Identifier; - use dpp::platform_value::Value; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::tokens::token_order::TokenOrder; + use dpp::tokens::token_order_side::TokenOrderSide; use dpp::version::PlatformVersion; - use grovedb::query_result_type::{QueryResultElement, QueryResultType}; - use grovedb::{PathQuery, Query, SizedQuery}; - use std::collections::{BTreeMap, BTreeSet}; + use std::collections::BTreeSet; const OWNER: [u8; 32] = [7; 32]; @@ -424,34 +384,25 @@ mod tests { (drive, contract) } - /// An `ask` of `OWNER` for `token` at price 100 and decimals 8. + /// An `ask` of `OWNER` for 10 of `token` at price 100 and decimals 8, as a placement + /// stores it. fn ask(id: u8, token: u8) -> Document { - DocumentV0 { - contract_version: None, + TokenOrder { id: Identifier::from([id; 32]), - owner_id: Identifier::from(OWNER), - properties: BTreeMap::from([ - ("tokenId".to_string(), Value::Identifier([token; 32])), - ("price".to_string(), Value::I64(100)), - ("amount".to_string(), Value::I64(10)), - ("remaining".to_string(), Value::I64(10)), - ("decimals".to_string(), Value::I64(8)), - ]), - revision: Some(1), - created_at: Some(1_000), - updated_at: Some(1_000), - transferred_at: None, - created_at_block_height: Some(1), - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - moderated_at: None, - moderated_by: None, + side: TokenOrderSide::Ask, + maker_id: Identifier::from(OWNER), + token_id: Identifier::from([token; 32]), + price: 100, + amount: 10, + remaining: 10, + escrow: 0, + decimals: 8, } - .into() + .new_document(&BlockInfo { + time_ms: 1_000, + ..BlockInfo::default() + }) + .expect("expected the ask document") } fn store(drive: &Drive, contract: &DataContract, document: &Document) { @@ -478,9 +429,9 @@ mod tests { .expect("expected to store the ask"); } - fn delete(contract: &DataContract, ids: &[u8]) -> DriveOperation<'static> { - DriveOperation::DocumentOperation(DocumentOperationType::DeleteDocuments { - document_ids: ids.iter().map(|id| Identifier::from([*id; 32])).collect(), + fn delete(contract: &DataContract, id: u8) -> DriveOperation<'static> { + DriveOperation::DocumentOperation(DocumentOperationType::DeleteDocument { + document_id: Identifier::from([id; 32]), contract_info: DataContractInfo::OwnedDataContract(contract.clone()), document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr("ask"), }) @@ -501,44 +452,16 @@ mod tests { /// Every tree and item under the `ask` document type, by path. fn ask_trees(drive: &Drive) -> BTreeSet>> { - let platform_version = PlatformVersion::latest(); - let root = vec![ - vec![RootTree::DataContractDocuments as u8], - SystemDataContract::TokenMarket.id().to_vec(), - vec![1u8], - b"ask".to_vec(), - ]; - let mut paths = BTreeSet::new(); - let mut pending = vec![root]; - while let Some(path) = pending.pop() { - let mut query = Query::new(); - query.insert_all(); - let (rows, _) = drive - .grove - .query_raw( - &PathQuery::new(path.clone(), SizedQuery::new(query, None, None)), - true, - true, - true, - QueryResultType::QueryKeyElementPairResultType, - None, - &platform_version.drive.grove_version, - ) - .value - .expect("expected to read the ask trees"); - for row in rows.elements { - let QueryResultElement::KeyElementPairResultItem((key, element)) = row else { - panic!("expected a key and an element"); - }; - let mut child = path.clone(); - child.push(key); - if element.is_any_tree() { - pending.push(child.clone()); - } - paths.insert(child); - } - } - paths + subtree_paths( + drive, + vec![ + vec![RootTree::DataContractDocuments as u8], + SystemDataContract::TokenMarket.id().to_vec(), + vec![1u8], + b"ask".to_vec(), + ], + PlatformVersion::latest(), + ) } /// One owner's asks of two tokens share the owner's `byOwner` bucket. Deleted by two @@ -550,16 +473,13 @@ mod tests { store(&drive, &contract, &ask(1, 0xA1)); store(&drive, &contract, &ask(2, 0xA2)); let with_asks = ask_trees(&drive); - apply( - &drive, - vec![delete(&contract, &[1]), delete(&contract, &[2])], - ); + apply(&drive, vec![delete(&contract, 1), delete(&contract, 2)]); let (separately, contract) = market(); store(&separately, &contract, &ask(1, 0xA1)); store(&separately, &contract, &ask(2, 0xA2)); - apply(&separately, vec![delete(&contract, &[1])]); - apply(&separately, vec![delete(&contract, &[2])]); + apply(&separately, vec![delete(&contract, 1)]); + apply(&separately, vec![delete(&contract, 2)]); let left = ask_trees(&drive); assert_eq!(left, ask_trees(&separately)); @@ -572,8 +492,9 @@ mod tests { ); } - /// The same with the insert sent as a group of operations for one document type: a - /// group's operations are built against the batch's earlier ones too. + /// As `should_insert_into_a_tree_an_earlier_deletion_of_the_batch_empties`, with the + /// insert sent as a group of operations for one document type: a group's operations are + /// built against the batch's earlier ones too. #[test] fn should_insert_through_a_group_into_a_tree_an_earlier_deletion_empties() { let (drive, contract) = market(); @@ -582,7 +503,7 @@ mod tests { apply( &drive, vec![ - delete(&contract, &[1]), + delete(&contract, 1), DriveOperation::DocumentOperation( DocumentOperationType::MultipleDocumentOperationsForSameContractDocumentType { document_operations: DocumentOperationsForContractDocumentType { @@ -608,9 +529,11 @@ mod tests { assert_eq!(ask_trees(&drive), ask_trees(&only_replacement)); } - /// The same two deletions through the converter that returns a batch instead of applying - /// it (`convert_drive_operations_to_grove_operations` 2, protocol version 15): applied, - /// its batch leaves the trees deleting the two in two batches leaves. + /// The deletions of `should_remove_a_tree_two_operations_of_one_batch_empty_together` + /// through the converter that returns a batch instead of applying it + /// (`convert_drive_operations_to_grove_operations` 2, protocol version 15): applied, its + /// batch leaves the trees deleting the two in two batches leaves. It sits here, beside + /// the helpers it shares. #[test] fn should_remove_a_shared_tree_through_the_raw_converter() { let platform_version = PlatformVersion::latest(); @@ -620,7 +543,7 @@ mod tests { let transaction = drive.grove.start_transaction(); let batch = drive .convert_drive_operations_to_grove_operations( - vec![delete(&contract, &[1]), delete(&contract, &[2])], + vec![delete(&contract, 1), delete(&contract, 2)], &BlockInfo::default(), Some(&transaction), platform_version, @@ -636,8 +559,8 @@ mod tests { let (separately, contract) = market(); store(&separately, &contract, &ask(1, 0xA1)); store(&separately, &contract, &ask(2, 0xA2)); - apply(&separately, vec![delete(&contract, &[1])]); - apply(&separately, vec![delete(&contract, &[2])]); + apply(&separately, vec![delete(&contract, 1)]); + apply(&separately, vec![delete(&contract, 2)]); let left = ask_trees(&drive); assert_eq!(left, ask_trees(&separately)); @@ -659,7 +582,7 @@ mod tests { apply( &drive, vec![ - delete(&contract, &[1]), + delete(&contract, 1), DriveOperation::DocumentOperation(DocumentOperationType::AddDocument { owned_document_info: OwnedDocumentInfo { document_info: DocumentRefInfo((&replacement, None)), diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs index 29580e6edb6..6dd2c3e4a52 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v2/mod.rs @@ -10,16 +10,23 @@ use grovedb::TransactionArg; impl Drive { /// Version 2 of [`Drive::convert_drive_operations_to_grove_operations`] (protocol version - /// 15): version 1, with every operation built against the operations of the batch built - /// before it, as `apply_drive_operations` 2 builds them - /// ([`DriveOperation::append_low_level_drive_operations_after_ttl_drain`]). Two deletions - /// that empty an index tree together remove it, and an insert into a tree an earlier deletion - /// of the batch empties keeps the tree. Credits that repaid an identity's debt and ephemeral - /// operations are refused as by version 0, for the same reasons. A batch writing one token - /// balance, supply, pool total or escrow more than once is refused first, as - /// `apply_drive_operations` refuses it - /// ([`DriveOperation::refuse_repeated_token_balance_writes`]): each such write sets the value - /// computed from state before the batch, so all but the last would be lost. + /// 15): version 1, with every document operation built against the operations of the batch + /// built before it, as `apply_drive_operations` 2 builds them + /// ([`DriveOperation::append_low_level_drive_operations_after_ttl_drain`]); other operation + /// kinds are built as before. Two deletions that empty an index tree together remove it, + /// and an insert into a tree an earlier deletion of the batch empties keeps the tree. + /// Credits that repaid an identity's debt and ephemeral operations are refused as by version + /// 0, for the same reasons. + /// + /// The batch is prepared as `apply_drive_operations` prepares it, because each write of an + /// identity balance, a contract fee pot, a prefunded specialized balance or a token key + /// sets the value computed from state before the batch, so all but the last of two would + /// be lost: a batch writing one token balance, supply, pool total or escrow more than once + /// is refused ([`DriveOperation::refuse_repeated_token_balance_writes`]), and repeated + /// balance writes are merged into one ([`DriveOperation::merge_balance_writes`]). + /// + /// No platform code calls it at protocol version 15: the proposer payout credits through + /// `apply_drive_operations` from protocol version 14. #[inline(always)] pub(super) fn convert_drive_operations_to_grove_operations_v2( &self, @@ -35,6 +42,7 @@ impl Drive { transaction, platform_version, )?; + let drive_batch_operations = DriveOperation::merge_balance_writes(drive_batch_operations)?; self.prepare_drive_operations_time_range_ttl( &drive_batch_operations, block_info, @@ -76,3 +84,112 @@ impl Drive { )) } } + +#[cfg(test)] +mod tests { + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::util::batch::drive_op_batch::TokenOperationType; + use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; + use crate::util::batch::{DriveOperation, IdentityOperationType}; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::test_utils::identities::create_test_identity; + use dpp::block::block_info::BlockInfo; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + + /// A batch writing one token key twice is refused: a frozen holding destroy naming one + /// listing twice, and two destroys of one holding. + #[test] + fn should_refuse_repeated_token_writes() { + let latest = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(latest)); + let destroy = |order_ids: Vec| { + DriveOperation::TokenOperation(TokenOperationType::TokenDestroyFrozenHolding { + token_id: Identifier::from([5; 32]), + holder_id: Identifier::from([6; 32]), + balance_amount: 10, + destroyed_amount: 10 + order_ids.len() as u64, + order_ids, + }) + }; + let listing = Identifier::from([7; 32]); + for (case, operations) in [ + ("one listing twice", vec![destroy(vec![listing, listing])]), + ("one holding twice", vec![destroy(vec![]), destroy(vec![])]), + ] { + let refused = drive.convert_drive_operations_to_grove_operations( + operations, + &BlockInfo::default(), + None, + latest, + ); + assert!( + matches!( + &refused, + Err(Error::Drive(DriveError::CorruptedCodeExecution(message))) + if message.contains("more than once") + ), + "{case}: {refused:?}" + ); + } + } + + /// Two credits to one identity in one batch: generation 1 (protocol version 14) builds + /// both from the committed balance, so its batch writes the balance twice and the first + /// credit would be lost; generation 2 merges them into one write of both. + #[test] + fn should_merge_repeated_balance_writes_from_protocol_version_15() { + let latest = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(latest)); + let identity_id = [9; 32]; + create_test_identity(&drive, identity_id, Some(1), None, latest) + .expect("expected to create the identity"); + let before = drive + .fetch_identity_balance(identity_id, None, latest) + .expect("expected to fetch the balance") + .expect("expected a balance"); + let credits = || { + vec![ + DriveOperation::IdentityOperation(IdentityOperationType::AddToIdentityBalance { + identity_id, + added_balance: 100, + }), + DriveOperation::IdentityOperation(IdentityOperationType::AddToIdentityBalance { + identity_id, + added_balance: 50, + }), + ] + }; + + let generation_1 = PlatformVersion::get(14).expect("expected protocol version 14"); + let blind = drive + .convert_drive_operations_to_grove_operations( + credits(), + &BlockInfo::default(), + None, + generation_1, + ) + .expect("expected the conversion"); + assert_eq!(blind.len(), 2, "two writes of one balance: {blind:?}"); + + let merged = drive + .convert_drive_operations_to_grove_operations( + credits(), + &BlockInfo::default(), + None, + latest, + ) + .expect("expected the conversion"); + assert_eq!(merged.len(), 1, "one write of both credits: {merged:?}"); + drive + .grove_apply_batch(merged, false, None, &latest.drive) + .expect("expected the batch to apply"); + assert_eq!( + drive + .fetch_identity_balance(identity_id, None, latest) + .expect("expected to fetch the balance"), + Some(before + 150) + ); + } +} diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index 2b9eb2bd32b..4b47dd91783 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -705,7 +705,7 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { TokenOperationType::TokenDestroyFrozenHolding { token_id, holder_id, - listings, + order_ids, .. } => [ TokenBalanceKey::Holder(*token_id, *holder_id), @@ -713,9 +713,9 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { ] .into_iter() .chain( - listings + order_ids .iter() - .map(|(order_id, _)| TokenBalanceKey::OrderEscrow(*token_id, *order_id)), + .map(|order_id| TokenBalanceKey::OrderEscrow(*token_id, *order_id)), ) .collect(), // An offer's escrow holds credits, which its placement, fills and cancel move through diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index 60cfe60fc43..5132067bd27 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -222,10 +222,11 @@ pub enum TokenOperationType { /// What the escrow holds after the release remaining_after: TokenAmount, }, - /// Destroys a frozen identity's holding of a token: `balance_amount` from its balance and what - /// each of its `listings` (order id, amount) holds in escrow, and lowers the total supply by - /// all of it once (the token marketplace, protocol version 15). The listings' `ask` - /// documents are deleted by document operations of the same batch. + /// Destroys a frozen identity's holding of a token: `balance_amount` from its balance and the + /// escrow of each of its listings `order_ids`, and lowers the total supply once by + /// `destroyed_amount`, the action's `destroyed_amount` (the token marketplace, protocol + /// version 15). The listings' `ask` documents are deleted by document operations of the + /// same batch. TokenDestroyFrozenHolding { /// The token id token_id: Identifier, @@ -233,8 +234,11 @@ pub enum TokenOperationType { holder_id: Identifier, /// What its balance holds balance_amount: TokenAmount, - /// Its open listings and what each holds in escrow - listings: Vec<(Identifier, TokenAmount)>, + /// Its open listings, whose escrow is removed + order_ids: Vec, + /// Everything destroyed, which the total supply loses: the balance and what the + /// listings hold in escrow + destroyed_amount: TokenAmount, }, /// Holds `credits` in escrow for the new offer `order_id` (the token marketplace, protocol /// version 15). The credits come out of the buyer's balance through an identity balance @@ -273,17 +277,23 @@ impl DriveLowLevelOperationConverter for TokenOperationType { token_id, holder_id, balance_amount, - listings, + order_ids, + destroyed_amount, } => { + // The balance and supply writes are a burn's (`token_burn_operations` 0), with the + // supply lowered once by everything destroyed. A new burn generation has to + // decide how a frozen holding is destroyed too, so it is refused here until then. + match platform_version.drive.methods.token.update.burn { + 0 => {} + version => { + return Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenOperationType::TokenDestroyFrozenHolding".to_string(), + known_versions: vec![0], + received: version, + })) + } + } let token_id = token_id.to_buffer(); - let total = listings - .iter() - .try_fold(balance_amount, |total, (_, amount)| { - total.checked_add(*amount) - }) - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "a frozen holding is more tokens than a u64 holds", - )))?; let mut batch_operations = drive.remove_from_identity_token_balance_operations( token_id, holder_id.to_buffer(), @@ -292,7 +302,7 @@ impl DriveLowLevelOperationConverter for TokenOperationType { transaction, platform_version, )?; - for (order_id, _) in listings { + for order_id in order_ids { batch_operations.extend(drive.remove_token_order_escrow_operations( token_id, order_id.to_buffer(), @@ -302,7 +312,7 @@ impl DriveLowLevelOperationConverter for TokenOperationType { } batch_operations.extend(drive.remove_from_token_total_supply_operations( token_id, - total, + destroyed_amount, estimated_costs_only_with_layer_info, transaction, platform_version, diff --git a/packages/rs-drive/src/util/test_helpers/mod.rs b/packages/rs-drive/src/util/test_helpers/mod.rs index 201d7d76d45..f4576cd13f3 100644 --- a/packages/rs-drive/src/util/test_helpers/mod.rs +++ b/packages/rs-drive/src/util/test_helpers/mod.rs @@ -33,9 +33,11 @@ use dpp::tests::json_document::json_document_to_contract_with_ids; #[cfg(feature = "fixtures-and-mocks")] use dpp::version::PlatformVersion; #[cfg(feature = "fixtures-and-mocks")] +use grovedb::query_result_type::QueryResultElement; +#[cfg(feature = "fixtures-and-mocks")] use grovedb::query_result_type::QueryResultType; #[cfg(feature = "fixtures-and-mocks")] -use grovedb::{PathQuery, Query}; +use grovedb::{PathQuery, Query, SizedQuery}; #[cfg(feature = "fixtures-and-mocks")] use std::collections::{BTreeMap, BTreeSet}; @@ -80,6 +82,47 @@ pub mod setup; /// test utils pub mod test_utils; +#[cfg(feature = "fixtures-and-mocks")] +/// Every tree and item at or below `root`, by path: the shape of a subtree, for tests that +/// compare it before and after a batch. +pub fn subtree_paths( + drive: &Drive, + root: Vec>, + platform_version: &PlatformVersion, +) -> BTreeSet>> { + let mut paths = BTreeSet::new(); + let mut pending = vec![root]; + while let Some(path) = pending.pop() { + let mut query = Query::new(); + query.insert_all(); + let (rows, _) = drive + .grove + .query_raw( + &PathQuery::new(path.clone(), SizedQuery::new(query, None, None)), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + &platform_version.drive.grove_version, + ) + .value + .expect("expected to read the subtree"); + for row in rows.elements { + let QueryResultElement::KeyElementPairResultItem((key, element)) = row else { + panic!("expected a key and an element"); + }; + let mut child = path.clone(); + child.push(key); + if element.is_any_tree() { + pending.push(child.clone()); + } + paths.insert(child); + } + } + paths +} + #[cfg(feature = "fixtures-and-mocks")] /// Applies to Drive a JSON contract from the file system. pub fn setup_contract( diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 4cc7ac3a0f4..9d9a28bc507 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -1363,9 +1363,10 @@ impl Drive { // the token, the signer and the nonce, so only this transition creates // a document with it: one holding the transition's terms proves the // placement even after fills, which leave `remaining` lower (from the - // amount down to 1) and change nothing else. A listing filled entirely - // or cancelled is gone: then the `orderPlaced` record of a token keeping - // transfer history proves the placement. + // amount down to 1) and change nothing else. A listing filled entirely, + // cancelled or destroyed with a frozen holding is gone: then the + // `orderPlaced` record of a token keeping transfer history proves the + // placement. TokenTransition::PlaceAsk(token_place_ask_transition) => { // A placement is never a group action: the node refuses group // information on it, so a transition carrying some was not diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs index 9096d828ea6..cb6cdba0d1c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs @@ -1,94 +1,18 @@ +use crate::version::drive_versions::drive_state_transition_method_versions::v4::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4; use crate::version::drive_versions::drive_state_transition_method_versions::{ - DriveDocumentFromActionVersions, DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions, - DriveStateTransitionMethodVersions, DriveStateTransitionOperationMethodVersions, + DriveStateTransitionMethodVersions, }; -use crate::version::drive_versions::DriveDataContractOperationMethodVersions; -// Protocol 15: v4 with `token_destroy_frozen_funds_transition` 1, which destroys what a frozen -// identity's open listings hold with its balance. +/// Protocol 15: `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4` with +/// `token_destroy_frozen_funds_transition` 1, which destroys what a frozen identity's open +/// listings hold with its balance. pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5: DriveStateTransitionMethodVersions = DriveStateTransitionMethodVersions { - operations: DriveStateTransitionOperationMethodVersions { - finalization_tasks: 0, - contracts: DriveDataContractOperationMethodVersions { - finalization_tasks: 0, - }, - }, convert_to_high_level_operations: DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions { - data_contract_create_transition: 1, // changed: also emits contract group registration and membership operations for version 1 create transitions - data_contract_update_transition: 0, - document_create_transition: 0, - document_delete_transition: 0, - document_index_only_delete_transition: 0, - // PROTOCOL_VERSION_13: v1 rewrites a transferred or purchased - // DPNS domain document's `records.identity` to the new owner - // so the username resolves to the buyer. v0 stays for - // PROTOCOL_VERSION_12 chain replay. - document_purchase_transition: 1, // changed - document_replace_transition: 0, - document_transfer_transition: 1, // changed - document_update_price_transition: 1, // changed - token_burn_transition: 0, - token_mint_transition: 0, - token_transfer_transition: 0, - documents_batch_transition: 1, // changed: also deletes the lapsed contract suspensions the batch action carries - identity_create_transition: 0, - identity_create_from_addresses_transition: 0, - identity_credit_transfer_transition: 0, - identity_credit_withdrawal_transition: 0, - identity_top_up_transition: 0, - identity_top_up_from_addresses_transition: 0, - identity_update_transition: 1, - identity_key_limits_update_transition: 0, - contract_user_moderation_transition: 0, - contract_fee_claim_transition: 0, - masternode_vote_transition: 0, - bump_identity_data_contract_nonce: 0, - bump_identity_nonce: 0, - partially_use_asset_lock: 0, - token_freeze_transition: 0, - token_unfreeze_transition: 0, - token_emergency_action_transition: 0, token_destroy_frozen_funds_transition: 1, // changed: also destroys the frozen identity's escrowed listings - token_config_update_transition: 0, - token_claim_transition: 0, - token_direct_purchase_transition: 0, - token_set_price_for_direct_purchase_transition: 0, - token_shield_transition: 0, - token_unshield_transition: 0, - token_shielded_transfer_transition: 0, - token_mint_to_pool_transition: 0, - token_burn_from_pool_transition: 0, - token_claim_to_pool_transition: 0, - token_direct_purchase_to_pool_transition: 0, - token_place_ask_transition: 0, - token_fill_ask_transition: 0, - token_cancel_order_transition: 0, - token_place_bid_transition: 0, - token_fill_bid_transition: 0, - identity_credit_transfer_to_addresses_transition: 0, - address_funds_transfer_transition: 0, - address_credit_withdrawal_transition: 0, - address_funding_from_asset_lock_transition: 0, - // PROTOCOL_VERSION_14: v1 also records the nullifiers the - // shield's actions reveal, as the spends do. v0 stays for - // PROTOCOL_VERSION_12 and 13 chain replay. - shield_transition: 1, // changed - shield_from_asset_lock_transition: 1, // changed - shielded_transfer_transition: 0, - unshield_transition: 0, - shielded_withdrawal_transition: 0, - identity_create_from_shielded_pool_transition: 0, - shield_from_identity_transition: 0, - identity_top_up_from_shielded_pool_transition: 0, - token_shielded_transfer_with_shielded_fee_transition: 0, - token_unshield_with_shielded_fee_transition: 0, - token_purchase_from_shielded_pool_transition: 0, + ..DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4.convert_to_high_level_operations }, - document_from_action: DriveDocumentFromActionVersions { - document_from_create_transition_action: 1, // changed - document_from_replace_transition_action: 1, // changed - }, + ..DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4 }; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 5d7388783e7..1d2d645957a 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -45,6 +45,14 @@ use crate::version::drive_versions::{ /// * **Frozen listings**: `DRIVE_TOKEN_METHOD_VERSIONS_V3` reads a frozen identity's open /// listings (`order_escrow.fetch_frozen_listings`) and `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5` /// has `TokenDestroyFrozenFunds` destroy what they hold with its balance. +/// * **Document operations built against their batch**: `apply_drive_operations` 2 also builds +/// every document operation of a batch against the batch's operations built before it, so a +/// deletion sees the batch's earlier writes as pending: two deletions that empty an index +/// tree together remove it and refund it, and an insert into a tree an earlier deletion of +/// the batch empties keeps the tree. Other operation kinds are built as before. +/// `convert_drive_operations_to_grove_operations` 2 builds its batch the same way, and, as +/// `apply_drive_operations` does, refuses a batch writing one token key twice and merges +/// repeated balance writes. /// /// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { @@ -70,8 +78,8 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { ..DRIVE_IDENTITY_METHOD_VERSIONS_V2 }, batch_operations: DriveBatchOperationsMethodVersion { - convert_drive_operations_to_grove_operations: 2, // changed in v10: each operation is built against the batch's earlier ones, and a batch writing one token balance, supply, pool total or escrow twice is refused - apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions into one covering both + convert_drive_operations_to_grove_operations: 2, // changed in v10: each document operation is built against the batch's earlier ones; a batch writing one token balance, supply, pool total or escrow twice is refused, and repeated balance writes are merged + apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions into one covering both; each document operation is built against the batch's earlier ones }, prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { estimated_cost_for_prefunded_specialized_balance_update: 2, // changed in v10: keeps a deeper prefunded balances layer description diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 0e6a5dc9867..dc6f31499e3 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -627,7 +627,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, - max_frozen_listings_destroyed_per_transition: 16, + max_frozen_listings_destroyed_per_transition: None, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, max_shielded_transition_actions: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 151e0969acd..8b3ba235f1f 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -105,8 +105,11 @@ pub struct SystemLimits { /// conversions are blind to each other: the check that decides whether an index group tree /// has become empty and should be removed sees committed state plus only the operations of /// its own conversion. From protocol version 15, `apply_drive_operations` 2 builds every - /// operation against the batch's operations built before it, so this blindness is gone - /// there; protocol version 14 and earlier keep it. + /// document operation against the batch's operations built before it, so this blindness is + /// gone there for documents; protocol version 14 and earlier keep it. Every other operation + /// kind is still built blind at 15 (token history documents, a token's escrow tree, group + /// actions): two token history entries of one kind for one token and owner in one batch + /// would each queue the same empty index tree, so the cap stays load-bearing for them. /// /// While the cap is 1, no two document operations can share a GroveDB batch *by way of a /// batch state transition*, so on that route the blindness has nothing to act on. Raise it @@ -315,9 +318,9 @@ pub struct SystemLimits { /// state validation v2 (protocol version 14) and never reached before. pub max_contenders_per_contest: u16, /// Most open listings of a frozen identity one `TokenDestroyFrozenFunds` destroys with its - /// balance, oldest first; another destroys the next ones. Read from protocol version 15 - /// (`order_escrow.fetch_frozen_listings`) and never reached before. - pub max_frozen_listings_destroyed_per_transition: u16, + /// balance, oldest first; another destroys the next ones. `None` before protocol version 15, + /// which reads it (`order_escrow.fetch_frozen_listings`). + pub max_frozen_listings_destroyed_per_transition: Option, // This the max redemption cycles we can process if we don't use a constant distribution // For a constant perpetual distribution this is very cheap since it's just a multiplication // For other distributions we much calculate at each cycle the rewards, so we don't want to diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 68715266abc..2ef8f8a3bdd 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -73,7 +73,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, - max_frozen_listings_destroyed_per_transition: 16, + max_frozen_listings_destroyed_per_transition: None, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index f8b84557ae9..3a8249433f9 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -54,7 +54,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, - max_frozen_listings_destroyed_per_transition: 16, + max_frozen_listings_destroyed_per_transition: None, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 569713e8898..de3ce5b4a90 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -56,7 +56,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, - max_frozen_listings_destroyed_per_transition: 16, + max_frozen_listings_destroyed_per_transition: None, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index d44940193aa..c8d4b922ff4 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -99,10 +99,6 @@ use crate::version::system_limits::SystemLimits; /// the charters contract's `electedCharter.members` `maxItems`) and the declaration's /// `maxAddedModerators`. It joined this table in place while protocol version 14 was /// unreleased, and was backfilled into the earlier tables, whose parsers never read it. -/// * Token marketplace (protocol version 15): a `TokenDestroyFrozenFunds` destroys at most 16 -/// open listings of the frozen identity with its balance -/// (`max_frozen_listings_destroyed_per_transition`, in every table; nothing before 15 reads -/// it). /// * Contested documents (protocol version 14): a contest accepts at most 1,000 contenders /// (`max_contenders_per_contest`, backfilled into the earlier tables, whose validation never /// reads it). The end of a poll within the cap tallies and cleans up every contender in one @@ -181,7 +177,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_contract_moderation_added_moderators: 15, max_moderation_charter_elected_members: 15, max_contenders_per_contest: 1_000, - max_frozen_listings_destroyed_per_transition: 16, + max_frozen_listings_destroyed_per_transition: None, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v5.rs b/packages/rs-platform-version/src/version/system_limits/v5.rs index e0a87c6a570..f54d043410e 100644 --- a/packages/rs-platform-version/src/version/system_limits/v5.rs +++ b/packages/rs-platform-version/src/version/system_limits/v5.rs @@ -8,7 +8,11 @@ use crate::version::system_limits::SystemLimits; /// 1 MiB (`max_compiled_contract_size`, `None` before). Registration, update and a token /// configuration update refuse a contract over it, Drive refuses to store one, and proof /// verification refuses a compiled contract over it. +/// * Frozen listings: a `TokenDestroyFrozenFunds` destroys at most 16 open listings of the +/// frozen identity with its balance (`max_frozen_listings_destroyed_per_transition`, `None` +/// before). pub const SYSTEM_LIMITS_V5: SystemLimits = SystemLimits { max_compiled_contract_size: Some(1_048_576), // changed: contracts stored compiled take at most 1 MiB + max_frozen_listings_destroyed_per_transition: Some(16), // changed: a frozen identity's destroy takes at most 16 listings ..SYSTEM_LIMITS_V4 }; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index cd10a4cccd3..c36ef114f2c 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -281,14 +281,22 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// /// 14. **Frozen listings**: a `TokenDestroyFrozenFunds` also destroys what the frozen identity's /// open listings of the token hold in escrow, the oldest first and at most -/// `max_frozen_listings_destroyed_per_transition` (16) per transition, and deletes their -/// `ask` documents. `DRIVE_TOKEN_METHOD_VERSIONS_V3` reads them +/// `max_frozen_listings_destroyed_per_transition` (16, `SYSTEM_LIMITS_V5`) per transition, +/// and deletes their `ask` documents. `DRIVE_TOKEN_METHOD_VERSIONS_V3` reads them /// (`order_escrow.fetch_frozen_listings` `Some(0)`, `None` before) and /// `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V5` destroys them /// (`token_destroy_frozen_funds_transition` 1). The recorded amount is everything destroyed. +/// So that the deletes of listings sharing index trees remove those trees, +/// `apply_drive_operations` 2 builds every document operation of a batch against the +/// batch's operations built before it, which changes the state and fees of any batch whose +/// document operations share index trees: two deletions emptying a tree remove and refund +/// it, and an insert into a tree an earlier deletion of the batch empties keeps it. +/// `convert_drive_operations_to_grove_operations` 2 builds its batch the same way, refuses +/// repeated token writes and merges repeated balance writes, as `apply_drive_operations` +/// does. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, - drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description; apply_drive_operations 2 merges each operation's layer descriptions (note 10) + drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description; apply_drive_operations 2 merges each operation's layer descriptions (note 10); apply_drive_operations 2 and convert_drive_operations_to_grove_operations 2 build each document operation against the batch's earlier ones (note 14) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock @@ -315,7 +323,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { }, system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 3); the token market contract is selected (note 10); the token history contract v3 records the marketplace's events (note 13) fee_version: FEE_VERSION3, - system_limits: SYSTEM_LIMITS_V5, // changed: max_compiled_contract_size caps a contract stored compiled at 1 MiB (note 8) + system_limits: SYSTEM_LIMITS_V5, // changed: max_compiled_contract_size caps a contract stored compiled at 1 MiB (note 8); max_frozen_listings_destroyed_per_transition 16 (note 14) consensus: ConsensusVersions { tenderdash_consensus_version: 1, }, From e231e8132c5a3e53ba668beb0ce870c562e1166d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 29 Oct 2026 07:30:24 +0300 Subject: [PATCH 21/21] style(drive): sort the summable test imports Co-Authored-By: Claude Opus 5.5 --- .../v0/tests/summable_off_count_index_e2e_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/summable_off_count_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/summable_off_count_index_e2e_tests.rs index 8a91d015978..74ad1b7a8ca 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/summable_off_count_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/summable_off_count_index_e2e_tests.rs @@ -58,7 +58,6 @@ use crate::util::object_size_info::{ }; use crate::util::storage_flags::StorageFlags; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; -use dpp::version::PlatformVersion; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -67,6 +66,7 @@ use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; use dpp::platform_value::{Identifier, Value}; use dpp::prelude::DataContract; use dpp::tests::json_document::json_document_to_json_value; +use dpp::version::PlatformVersion; use grovedb::element::indexed::compute_avg_fixed_point; use grovedb::element::IndexAxis; use grovedb::Element;