diff --git a/CHANGELOG.md b/CHANGELOG.md index 36bca249401..afc6b9ef949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,22 @@ genesis. A marketplace transition of a token that keeps transfer history writes one of these documents, owned by its signer, and pays for it. See [#5531](https://github.com/dashpay/platform/pull/5531). +- **drive (Rust API migration):** destroying a frozen identity's funds also destroys + its escrowed token listings from protocol version 15. + `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: 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 dd945117bac..0d19045a837 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -232,6 +232,22 @@ that the escrow is what the offer holds after its sales (`TokenOrder::escrow_aft is worth at its decimals, rounded up, less what its sellers were paid). A `TokenFillBid` is proven 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 @@ -247,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 7365d8decd8..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, - }, - buyer: Party { - identity: buyer, - signer: buyer_signer, - key: buyer_key, - nonce: 2, + ( + Market { + platform, + contract, + token_id, + seller, + buyer, }, - } + member, + ) } /// The guarantee the proof of a marketplace transition gives. A placement's binds its @@ -2097,7 +2119,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( @@ -2109,7 +2131,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 @@ -2991,7 +3013,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 @@ -3501,3 +3523,556 @@ mod history { assert!(market.history("orderCancelled").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; + 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::util::test_helpers::subtree_paths; + 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) { + 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 { + /// 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 (place, order_id) = self.buyer_places_ask(amount, PRICE).await; + self.assert_executes(&place, &at_time(time_ms)); + order_id + } + + /// The seller destroys the buyer's frozen funds. + async fn destroy_buyers_frozen_funds(&mut self) { + self.destroy_buyers_frozen_funds_charged().await; + } + + 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") + } + } + + /// 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>> { + 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. + 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). + #[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.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_the_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.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); + 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!( + 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}" + ); + } + + /// 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.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()); + 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_the_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)); + 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_the_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" + ); + } + + /// 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.transfer_to_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).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.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_the_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() + .system_limits + .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.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_the_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(); + } + + /// 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 (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 { + 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, + }, + )); + }, + 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 + /// 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 + .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.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_the_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)); + 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; + 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 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 > *recorded, "the destroy grew past what was proposed"); + 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] + async fn should_leave_a_frozen_identitys_offers() { + let mut market = market_with(make_token_tradeable_and_freezable, dash_to_credits!(1.0)); + 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_the_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" + ); + assert_eq!( + market.credit_escrow(order_id), + Some(10_000), + "the offer's escrowed credits survive the token destroy" + ); + } +} 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..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 @@ -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 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. //! //! 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); @@ -972,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, @@ -981,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) @@ -1041,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( @@ -1069,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. @@ -1078,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" @@ -1108,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); + } } } } @@ -1167,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" @@ -1194,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); + } } } } @@ -1243,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 { @@ -1266,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..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 @@ -66,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; @@ -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 new file mode 100644 index 00000000000..574016a6fc2 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/mod.rs @@ -0,0 +1,96 @@ +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. `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, + 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 => 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, + 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::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 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_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 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/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..236293bf897 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_frozen_token_listings/v0/mod.rs @@ -0,0 +1,93 @@ +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; +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 + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "the frozen listings are read only where their limit is set", + )))?, + ), + 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/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index fce8e86e6a5..8288c1c51bd 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.rs deleted file mode 100644 index 494d4061c21..00000000000 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition.rs +++ /dev/null @@ -1,107 +0,0 @@ -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}; - -impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransitionAction { - fn into_high_level_batch_drive_operations<'b>( - self, - _epoch: &Epoch, - owner_id: Identifier, - platform_version: &PlatformVersion, - ) -> Result>, Error> { - match platform_version - .drive - .methods - .state_transitions - .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) - } - version => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "TokenDestroyFrozenFundsTransitionAction::into_high_level_document_drive_operations" - .to_string(), - known_versions: vec![0], - received: version, - })), - } - } -} 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 new file mode 100644 index 00000000000..97556283c9d --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_destroy_frozen_funds_transition/mod.rs @@ -0,0 +1,393 @@ +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +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_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionAction; +use crate::util::batch::DriveOperation; + +mod v0; +mod v1; + +impl DriveHighLevelBatchOperationConverter for TokenDestroyFrozenFundsTransitionAction { + fn into_high_level_batch_drive_operations<'b>( + self, + _epoch: &Epoch, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_destroy_frozen_funds_transition + { + 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.into_high_level_batch_drive_operations_v1(owner_id), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenDestroyFrozenFundsTransitionAction::into_high_level_batch_drive_operations" + .to_string(), + known_versions: vec![0, 1], + received: version, + })), + } + } +} + +#[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 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::{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; + 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, + 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 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), + 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, + frozen_listings, + 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) + ) + } + + /// 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, + 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, 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(); + for (group, perform_action) in [ + (None, true), + (Some(in_group(true)), false), + (Some(in_group(false)), true), + ] { + let action = action(group, perform_action, vec![]); + let at_15 = convert(action.clone(), latest); + let at_14 = convert(action, 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(), 5, "{operations:?}"); + assert!(is_nonce(&operations[0])); + 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 + /// 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(), 6, "{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_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, 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, 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, .. })) = + operations.last() + else { + panic!("expected the history last: {operations:?}"); + }; + assert!(is_history_of(operations.last().unwrap(), destroyed)); + + let reconstructed = token_action + .associated_token_event() + .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_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..12cae5a7ccb --- /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 into_high_level_batch_drive_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/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..999b40935c6 --- /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,122 @@ +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 dpp::tokens::token_order_side::TokenOrderSide; +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::{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}; +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. 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, + ) -> 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(); + + let destroyed_amount = self.destroyed_amount()?; + + 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() { + ops.push(TokenOperation( + TokenOperationType::TokenDestroyFrozenHolding { + token_id: self.token_id(), + 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(), + ), + })); + } + + 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/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 03d47e47482..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 @@ -159,14 +159,16 @@ 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()?, }, ))) } - /// 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 + /// 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(), burn_action.burn_from_identifier(), @@ -210,7 +212,7 @@ impl TokenTransitionAction { TokenTransitionAction::DestroyFrozenFundsAction(destroy_action) => { TokenEvent::DestroyFrozenFunds( destroy_action.frozen_identity_id(), - destroy_action.amount(), + destroy_action.destroyed_amount()?, destroy_action.public_note().cloned(), ) } @@ -286,6 +288,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 bff0b56327d..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() + 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/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..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 @@ -8,6 +8,8 @@ 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 @@ -17,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 { @@ -56,6 +79,12 @@ impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 } } + fn frozen_listings(&self) -> Option<&FrozenListings> { + match self { + TokenDestroyFrozenFundsTransitionAction::V0(v0) => v0.frozen_listings.as_ref(), + } + } + fn public_note(&self) -> Option<&String> { match self { TokenDestroyFrozenFundsTransitionAction::V0(v0) => v0.public_note.as_ref(), @@ -125,6 +154,7 @@ mod tests { frozen_identity_id: Identifier::from(TEST_FROZEN_ID), amount: TEST_AMOUNT, public_note: Some("destroyed".to_string()), + 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 33c0fc151b9..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 @@ -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,10 +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 (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 @@ -39,6 +53,16 @@ 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, with the token market + /// contract their `ask` documents are deleted from; `None` when there are none + fn frozen_listings(&self) -> Option<&FrozenListings>; + + /// 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 fn token_position(&self) -> u16 { self.base().token_position() @@ -101,6 +125,10 @@ impl TokenDestroyFrozenFundsTransitionActionAccessorsV0 self.amount = amount; } + fn frozen_listings(&self) -> Option<&FrozenListings> { + self.frozen_listings.as_ref() + } + fn public_note(&self) -> Option<&String> { self.public_note.as_ref() } @@ -147,6 +175,7 @@ mod tests { frozen_identity_id: frozen, amount, public_note: note.map(|s| s.to_string()), + 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 f82e4865240..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 @@ -7,8 +7,8 @@ use dpp::identifier::Identifier; use dpp::state_transition::batch_transition::token_destroy_frozen_funds_transition::v0::TokenDestroyFrozenFundsTransitionV0; use dpp::ProtocolError; 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; @@ -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,24 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } }; + let frozen_listings = read_frozen_listings( + drive, + &base_action, + base.token_id(), + frozen_identity_id, + block_info, + &mut fee_result, + transaction, + platform_version, + )?; + Ok(( BatchedTransitionAction::TokenAction(TokenTransitionAction::DestroyFrozenFundsAction( TokenDestroyFrozenFundsTransitionActionV0 { base: base_action, frozen_identity_id, amount: token_amount, + frozen_listings, public_note: change_note.unwrap_or(public_note), } .into(), @@ -237,7 +249,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 +308,24 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } }; + let frozen_listings = read_frozen_listings( + drive, + &base_action, + base.token_id(), + *frozen_identity_id, + block_info, + &mut fee_result, + transaction, + platform_version, + )?; + Ok(( BatchedTransitionAction::TokenAction(TokenTransitionAction::DestroyFrozenFundsAction( TokenDestroyFrozenFundsTransitionActionV0 { base: base_action, frozen_identity_id: *frozen_identity_id, amount: token_amount, + frozen_listings, public_note: change_note.unwrap_or(public_note.clone()), } .into(), @@ -312,6 +336,65 @@ impl TokenDestroyFrozenFundsTransitionActionV0 { } } +/// 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 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, 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(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, + frozen_identity_id, + &block_info.epoch, + transaction, + platform_version, + )?; + fee_result.checked_add_assign(FeeResult::new_from_processing_fee(read_cost))?; + if listings.is_empty() { + return Ok(None); + } + Ok(Some(FrozenListings { + listings, + token_market_contract: drive.token_market_contract_fetch_info(platform_version)?, + })) +} + #[cfg(test)] #[allow(clippy::unnecessary_literal_unwrap)] mod tests { 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..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,6 +211,7 @@ mod tests { frozen_identity_id: Identifier::new([8u8; 32]), amount: 9, public_note: None, + frozen_listings: None, }, ), ); @@ -337,6 +338,7 @@ mod tests { frozen_identity_id: Identifier::new([8u8; 32]), amount: 1, public_note: 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 144bbad17a8..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,6 +1821,7 @@ fn make_destroy_v0() -> TokenDestroyFrozenFundsTransitionActionV0 { frozen_identity_id: Identifier::from([0xCC; 32]), amount: 400, public_note: Some("destroy note".to_string()), + 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 da0c12b3877..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 @@ -348,6 +348,389 @@ 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_deletion( + drive, + document_id, + 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_deletion( + drive, + 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 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); + } + } + 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, + block_info, + transaction, + platform_version, + )?; + batch_operations.extend(operations); + } + } + Ok(()) + } + + /// 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_deletion( + drive: &Drive, + document_id: Identifier, + 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)?; + 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 1fbb805866b..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 @@ -34,8 +34,14 @@ impl Drive { /// operation is priced below its own description. That raises the estimate of any batch /// whose operations describe a shared layer differently, not only the marketplace's: a /// 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. Applying a batch - /// is generation 1's. + /// the root of every document batch is now estimated at the nonce's depth. + /// + /// 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, @@ -118,37 +124,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, one description covering both of two of one layer (the + // 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. - 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() { merge_estimated_layer(batch_layers, path, layer, platform_version)?; } - converted } - }; - if forfeited { - forfeited_low_level_operations.append(&mut converted); - } else { - low_level_operations.append(&mut converted); } } @@ -330,4 +339,265 @@ mod tests { .max(deep_first_before.processing_fee) ); } + + 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::{ + 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; + use dpp::identifier::Identifier; + 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 std::collections::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 10 of `token` at price 100 and decimals 8, as a placement + /// stores it. + fn ask(id: u8, token: u8) -> Document { + TokenOrder { + id: Identifier::from([id; 32]), + side: TokenOrderSide::Ask, + maker_id: Identifier::from(OWNER), + token_id: Identifier::from([token; 32]), + price: 100, + amount: 10, + remaining: 10, + escrow: 0, + decimals: 8, + } + .new_document(&BlockInfo { + time_ms: 1_000, + ..BlockInfo::default() + }) + .expect("expected the ask document") + } + + 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, 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"), + }) + } + + 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>> { + 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 + /// 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" + ); + } + + /// 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(); + 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)); + } + + /// 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(); + 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] + 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/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..6dd2c3e4a52 --- /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,195 @@ +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 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, + drive_batch_operations: Vec, + block_info: &BlockInfo, + 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, + 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, + 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), + )) + } +} + +#[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 039825269df..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 @@ -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, @@ -660,6 +700,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, + order_ids, + .. + } => [ + TokenBalanceKey::Holder(*token_id, *holder_id), + TokenBalanceKey::Supply(*token_id), + ] + .into_iter() + .chain( + order_ids + .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..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 @@ -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,24 @@ 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 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, + /// The frozen identity + holder_id: Identifier, + /// What its balance holds + balance_amount: 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 /// operation of the same batch; the escrow sits under the prefunded specialized balances, @@ -254,6 +273,52 @@ impl DriveLowLevelOperationConverter for TokenOperationType { platform_version: &PlatformVersion, ) -> Result, Error> { match self { + TokenOperationType::TokenDestroyFrozenHolding { + token_id, + holder_id, + balance_amount, + 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 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 order_ids { + 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, + destroyed_amount, + 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-drive/src/util/test_helpers/mod.rs b/packages/rs-drive/src/util/test_helpers/mod.rs index 8923cd5d52f..e3e01a8bc10 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}; @@ -88,6 +90,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 2db16165854..8a71368329b 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/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..cb6cdba0d1c --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v5.rs @@ -0,0 +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::{ + DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions, + DriveStateTransitionMethodVersions, +}; + +/// 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 { + convert_to_high_level_operations: + DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions { + token_destroy_frozen_funds_transition: 1, // changed: also destroys the frozen identity's escrowed listings + ..DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4.convert_to_high_level_operations + }, + ..DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4 + }; 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 650f5adbce7..062072d3350 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 @@ -31,6 +31,10 @@ pub struct DriveTokenOrderEscrowMethodVersions { pub fetch_credits: OptionalFeatureVersion, pub estimated_costs_for_tokens: OptionalFeatureVersion, pub estimated_costs_for_credits: OptionalFeatureVersion, + /// 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 7f3280a7831..2fd99bceb25 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: None, estimated_costs_for_tokens: None, estimated_costs_for_credits: None, + 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 84a628706b2..51235bb1921 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 @@ -8,8 +8,9 @@ use crate::version::drive_versions::drive_token_method_versions::{ /// 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; and the token marketplace's escrow methods, `None` -/// before, are active. +/// side of the token conservation check; the token marketplace's escrow methods, `None` before, +/// are active; 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 { @@ -23,6 +24,7 @@ pub const DRIVE_TOKEN_METHOD_VERSIONS_V3: DriveTokenMethodVersions = DriveTokenM fetch_credits: Some(0), estimated_costs_for_tokens: Some(0), estimated_costs_for_credits: Some(0), + fetch_frozen_listings: Some(0), }, ..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 6189c2b976e..53fa791457f 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -6,6 +6,7 @@ use crate::version::drive_versions::drive_identity_method_versions::v2::DRIVE_ID use crate::version::drive_versions::drive_identity_method_versions::{ DriveIdentityCostEstimationMethodVersions, DriveIdentityMethodVersions, }; +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::drive_verify_method_versions::v4::DRIVE_VERIFY_METHOD_VERSIONS_V4; use crate::version::drive_versions::drive_vote_method_versions::v4::DRIVE_VOTE_METHOD_VERSIONS_V4; @@ -53,6 +54,17 @@ use crate::version::drive_versions::{ /// `cleanup.remove_vote_poll_end_date_query_operations`, which removes finished polls from /// the end date index, is `Some(0)` (`None` before). `DRIVE_VERIFY_METHOD_VERSIONS_V4` makes /// the yes/no proof verifiers `Some(0)` (`None` before). +/// * **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 { @@ -60,7 +72,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; order_escrow active + token: DRIVE_TOKEN_METHOD_VERSIONS_V3, // changed in v10: calculate_total_tokens_balance 2 counts the token marketplace escrow; order_escrow active; 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 contract: DriveContractMethodVersions { fee_pots: DriveContractFeePotMethodVersions { add_estimation_costs_for_contract_fee_pot_update: 1, // changed in v10: keeps a deeper prefunded balances layer description @@ -77,8 +90,8 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { ..DRIVE_IDENTITY_METHOD_VERSIONS_V2 }, batch_operations: DriveBatchOperationsMethodVersion { - apply_drive_operations: 2, // changed in v10: an estimate merges each operation's layer descriptions into one covering both - ..DRIVE_VERSION_V9.methods.batch_operations + 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 469ff4894bb..991e0551e61 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -634,6 +634,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: 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 d1c031d975f..70d95b4c3c0 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -104,7 +104,12 @@ 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 + /// 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 @@ -176,9 +181,10 @@ pub struct SystemLimits { /// identity contract nonce, so two transitions of one kind by one signer in a batch would /// write the same record; raising the cap needs the order id in that key. /// - /// 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, @@ -335,6 +341,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. `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 38ef587a2e6..bc59ac3b176 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -80,6 +80,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: 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 8c9198f9a44..14a214e827f 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -61,6 +61,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: 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 950f730fe29..991fd748ad7 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -63,6 +63,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: 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 ffce24773b5..039bdf72dd0 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -184,6 +184,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: 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 2a501ccc6a6..5586b617c68 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -313,9 +313,25 @@ 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. +/// +/// 16. **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, `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); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10); 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 12) + 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); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10); 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 12); apply_drive_operations 2 and convert_drive_operations_to_grove_operations 2 build each document operation against the batch's earlier ones (note 16) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: the yes/no poll's tally, record, clean-up and finish hook are Some(0) (note 10); 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 @@ -342,7 +358,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 12); the token history contract v3 records the marketplace's events (note 15) 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 16) consensus: ConsensusVersions { tenderdash_consensus_version: 1, },