From afabf8684812eda6e6184fd42c7d898093a95822 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 21 Apr 2026 19:15:51 +0800 Subject: [PATCH 1/2] test(drive): improve coverage for tokens subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 86 new tests targeting the lowest-coverage modules under packages/rs-drive/src/drive/tokens/ and their dispatch paths: - mint_many (0% → covered): 13 unit tests in rs-drive exercising the proportional weight distribution, u32::MAX weight clamp, last-recipient remainder, and total-supply update. Placed in rs-drive because TokenMintMany is only reachable internally via TokenOperationType, not via a user-facing state transition. - unfreeze (39% → covered): 11 drive-abci integration tests covering authorized/unauthorized actors, non-frozen-identity rejection, group multi-sig flows, and verifying the unfrozen state allows subsequent transfers. - burn (29% → covered): 21 new drive-abci integration tests added on top of the existing 6 — exercising supply-update read-back, public notes, zero-amount rejection, specific-identity/ContractOwner/NoOne/MainGroup authorization rules, sequential depletion, and several group-action error branches (already-signed, already-completed, modified-main-params, non-member proposer/confirmer, confirmer-with-note). - system (68% → covered): 21 inline unit tests across add_to_token_total_supply, remove_from_token_total_supply, fetch_token_total_supply, fetch_token_total_aggregated_identity_balances, and create_token_trees covering happy paths, overflow/underflow, saturation, first-mint semantics, corrupted-state errors, and idempotence. - info (73% → covered): 20 inline unit tests across fetch_identity_token_info, fetch_identity_token_infos, fetch_identities_token_infos, and queries.rs covering PathKeyNotFound→None coercion, partial-hit map branches, with_costs fee paths, and all four range-query branches. All 165 touched tests pass; no production bugs surfaced during coverage work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../batch/tests/token/burn/mod.rs | 2694 +++++++++++++++++ .../batch/tests/token/mint_many/mod.rs | 5 + .../batch/tests/token/mod.rs | 2 + .../batch/tests/token/unfreeze/mod.rs | 1857 ++++++++++++ .../fetch_identities_token_infos/v0/mod.rs | 250 ++ .../info/fetch_identity_token_info/v0/mod.rs | 216 ++ .../info/fetch_identity_token_infos/v0/mod.rs | 234 ++ .../rs-drive/src/drive/tokens/info/queries.rs | 206 ++ .../src/drive/tokens/mint_many/v0/mod.rs | 673 ++++ .../add_to_token_total_supply/v0/mod.rs | 236 ++ .../system/create_token_trees/v0/mod.rs | 186 ++ .../v0/mod.rs | 142 + .../system/fetch_token_total_supply/v0/mod.rs | 136 + .../remove_from_token_total_supply/v0/mod.rs | 134 + 14 files changed, 6971 insertions(+) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mint_many/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/unfreeze/mod.rs diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs index f4cedd5a8d0..ec0cdaf5a7c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs @@ -2,7 +2,9 @@ use super::*; mod token_burn_tests { use super::*; + use crate::execution::validation::state_transition::tests::add_tokens_to_identity; use dpp::state_transition::batch_transition::TokenBurnTransition; + use dpp::tokens::MAX_TOKEN_NOTE_LEN; #[test] fn test_token_burn() { @@ -893,4 +895,2696 @@ mod token_burn_tests { assert_eq!(balance1, Some(98663)); assert_eq!(balance2, Some(1337)); } + + // -------------------------------------------------------------- + // Expanded coverage tests + // -------------------------------------------------------------- + + #[test] + fn test_token_burn_updates_total_supply_correctly() { + // Burn should decrement the token's total supply by the same amount removed + // from the identity's balance. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + None::, + None, + None, + None, + platform_version, + ); + + // Sanity: total supply should start equal to the base_supply (100000). + let total_supply_before = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply_before, Some(100000)); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 25000, + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(75000)); + + let total_supply_after = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply_after, Some(75000)); + } + + #[test] + fn test_token_burn_with_public_note_succeeds() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + None::, + None, + None, + None, + platform_version, + ); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 500, + Some("burning some old tokens".to_string()), + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(99500)); + } + + #[test] + fn test_token_burn_with_public_note_too_big_fails() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + None::, + None, + None, + None, + platform_version, + ); + + let oversized_note = "x".repeat(MAX_TOKEN_NOTE_LEN + 1); + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 500, + Some(oversized_note), + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_)) + )] + ); + + // Balance unchanged + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(100000)); + } + + #[test] + fn test_token_burn_zero_amount_fails() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + None::, + None, + None, + None, + platform_version, + ); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 0, + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + // A zero burn is invalid and rejected at structure validation. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_)) + )] + ); + + // No change to balance + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(100000)); + + // Total supply unchanged + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(100000)); + } + + #[test] + fn test_token_burn_allowed_when_rule_is_contract_owner_explicit() { + // Explicitly set the burning rule to ContractOwner and verify the contract + // owner can still burn (distinct from the default-rules happy path). + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_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, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 1500, + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(98500)); + + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(98500)); + } + + #[test] + fn test_token_burn_allowed_by_specific_identity_rule() { + // Allow burning only by a specific (non-owner) identity. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (owner, _owner_signer, _owner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (burner, burner_signer, burner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let burner_id = burner.id(); + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + Some(move |token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Identity(burner_id), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + // Give the burner some tokens to burn. + add_tokens_to_identity(&platform, token_id, burner.id(), 40000); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + burner.id(), + contract.id(), + 0, + 15000, + None, + None, + &burner_key, + 2, + 0, + &burner_signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let burner_balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + burner.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(burner_balance, Some(25000)); + + // Owner balance untouched (still base_supply). + let owner_balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + owner.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(owner_balance, Some(100000)); + + // Total supply = 100000 (owner) + 40000 (added) - 15000 (burned) = 125000 + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(125000)); + } + + #[test] + fn test_token_burn_specific_identity_rule_rejects_other_identity() { + // Burning rule is Identity(burner); owner attempts to burn -> unauthorized. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (owner, owner_signer, owner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (burner, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let burner_id = burner.id(); + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + Some(move |token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Identity(burner_id), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + owner.id(), + contract.id(), + 0, + 1000, + None, + None, + &owner_key, + 2, + 0, + &owner_signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::UnauthorizedTokenActionError(_)), + .. + }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let owner_balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + owner.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(owner_balance, Some(100000)); + } + + #[test] + fn test_token_burn_rule_no_one_blocks_even_owner() { + // With NoOne rule, even the contract owner cannot burn. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::NoOne, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 1, + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::UnauthorizedTokenActionError(_)), + .. + }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(100000)); + + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(100000)); + } + + #[test] + fn test_token_burn_from_frozen_account_succeeds() { + // Burn validation does NOT check freeze status (only transfer does), + // so a frozen account can still have its tokens burned. This test locks + // in the current behavior and covers the removal path for frozen + // identities. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (owner, owner_signer, owner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_freeze_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, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + // Freeze the contract owner's own balance. + let freeze_transition = BatchTransition::new_token_freeze_transition( + token_id, + owner.id(), + contract.id(), + 0, + owner.id(), + None, + None, + &owner_key, + 2, + 0, + &owner_signer, + platform_version, + None, + ) + .expect("expected to create freeze transition"); + + let freeze_serialized = freeze_transition + .serialize_to_bytes() + .expect("expected to serialize freeze"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[freeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process freeze"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit freeze"); + + // Attempt the burn on the frozen identity. + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + owner.id(), + contract.id(), + 0, + 20000, + None, + None, + &owner_key, + 3, + 0, + &owner_signer, + platform_version, + None, + ) + .expect("expected to create burn transition"); + + let burn_serialized = burn_transition + .serialize_to_bytes() + .expect("expected to serialize burn"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[burn_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process burn"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit burn"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + owner.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(80000)); + + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(80000)); + } + + #[test] + fn test_token_burn_from_identity_with_no_token_balance_record() { + // Authorized by rule but the burning identity has no token balance record at all. + // Should yield IdentityDoesNotHaveEnoughTokenBalanceError. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (owner, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (burner, burner_signer, burner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let burner_id = burner.id(); + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + Some(move |token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Identity(burner_id), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + // burner is authorized by the rule but has NO token balance record at all. + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + burner.id(), + contract.id(), + 0, + 1, + None, + None, + &burner_key, + 2, + 0, + &burner_signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError( + StateError::IdentityDoesNotHaveEnoughTokenBalanceError(_) + ), + .. + }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Burner's balance still absent (None). + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + burner.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, None); + + // Owner supply intact. + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(100000)); + } + + #[test] + fn test_token_burn_sequential_depletes_balance_and_supply() { + // Two successful burns in sequence should correctly decrement both + // the identity's balance and the total supply. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + None::, + None, + None, + None, + platform_version, + ); + + for (nonce, amount, expected_balance, expected_supply) in [ + (2u64, 30000u64, 70000u64, 70000u64), + (3, 40000, 30000, 30000), + ] { + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + amount, + None, + None, + &key, + nonce, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(expected_balance)); + + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(expected_supply)); + } + } + + #[test] + fn test_token_burn_by_group_single_member_sufficient_power() { + // Group rule, but the proposer alone has enough power to finalize the + // action in one shot (mirrors the analogous mint test). + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity_2, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(identity.id(), 5), (identity_2.id(), 1)].into(), + required_power: 5, + }), + )] + .into(), + ), + None, + platform_version, + ); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 2500, + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(97500)); + + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(97500)); + } + + #[test] + fn test_token_burn_group_action_resubmit_same_signer_fails() { + // Proposer + confirmer path, then proposer tries to submit the confirmation + // again -> GroupActionAlreadySignedByIdentityError. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity1, signer1, key1) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity2, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity1.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(identity1.id(), 1), (identity2.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity1.id(), + contract.id(), + 0, + 1000, + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key1, + 2, + 0, + &signer1, + platform_version, + None, + ) + .expect("expected to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Same signer tries to submit as OtherSigner -> already signed error. + let action_id = TokenBurnTransition::calculate_action_id_with_fields( + token_id.as_bytes(), + identity1.id().as_bytes(), + 2, + 1000, + ); + + let resubmit_transition = BatchTransition::new_token_burn_transition( + token_id, + identity1.id(), + contract.id(), + 0, + 1000, + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &key1, + 3, + 0, + &signer1, + platform_version, + None, + ) + .expect("expected to create resubmit transition"); + + let serialized = resubmit_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError( + StateError::GroupActionAlreadySignedByIdentityError(_) + ), + .. + }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Proposer's balance is unchanged because the action has not yet been + // finalized (only 1/2 power). + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity1.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(100000)); + } + + #[test] + fn test_token_burn_group_action_submit_after_completion_fails() { + // Three-member group with required power 2: proposer + one confirmer completes + // the action, then a third member tries to confirm -> AlreadyCompleted. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity1, signer1, key1) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity2, signer2, key2) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity3, signer3, key3) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity1.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [ + (identity1.id(), 1), + (identity2.id(), 1), + (identity3.id(), 1), + ] + .into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + // 1. Proposer + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity1.id(), + contract.id(), + 0, + 1000, + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key1, + 2, + 0, + &signer1, + platform_version, + None, + ) + .expect("expected to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let action_id = TokenBurnTransition::calculate_action_id_with_fields( + token_id.as_bytes(), + identity1.id().as_bytes(), + 2, + 1000, + ); + + // 2. Confirmer 2 completes (reaches required power 2). + let confirm_transition = BatchTransition::new_token_burn_transition( + token_id, + identity2.id(), + contract.id(), + 0, + 1000, + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &key2, + 2, + 0, + &signer2, + platform_version, + None, + ) + .expect("expected to create confirm transition"); + + let serialized = confirm_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Balance already burnt. + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity1.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(99000)); + + // 3. Third member tries to confirm an already-completed action. + let late_transition = BatchTransition::new_token_burn_transition( + token_id, + identity3.id(), + contract.id(), + 0, + 1000, + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &key3, + 2, + 0, + &signer3, + platform_version, + None, + ) + .expect("expected to create late transition"); + + let serialized = late_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::GroupActionAlreadyCompletedError(_)), + .. + }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Total supply only decremented once. + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(99000)); + } + + #[test] + fn test_token_burn_group_proposer_not_in_group_fails() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity_2, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity_3, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + // identity is NOT a member + members: [(identity_2.id(), 1), (identity_3.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 1000, + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expected to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::IdentityNotMemberOfGroupError(_)), + .. + }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(100000)); + } + + #[test] + fn test_token_burn_group_other_signer_not_in_group_fails() { + // Proposer succeeds, but a non-member tries to confirm. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity_2, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (outsider, outsider_signer, outsider_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(identity.id(), 1), (identity_2.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + // Proposer succeeds + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 1000, + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expected to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let action_id = TokenBurnTransition::calculate_action_id_with_fields( + token_id.as_bytes(), + identity.id().as_bytes(), + 2, + 1000, + ); + + // Outsider attempts to confirm. + let confirm_transition = BatchTransition::new_token_burn_transition( + token_id, + outsider.id(), + contract.id(), + 0, + 1000, + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &outsider_key, + 2, + 0, + &outsider_signer, + platform_version, + None, + ) + .expect("expected to create confirm transition"); + + let serialized = confirm_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::IdentityNotMemberOfGroupError(_)), + .. + }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Proposer balance still intact - action is still pending. + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(100000)); + } + + #[test] + fn test_token_burn_group_confirmer_with_note_fails() { + // Only the proposer may attach a note. The confirmer attempting to attach + // a note is rejected with TokenNoteOnlyAllowedWhenProposerError. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity1, signer1, key1) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity2, signer2, key2) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity1.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(identity1.id(), 1), (identity2.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + // Proposer with a valid note. + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity1.id(), + contract.id(), + 0, + 1500, + Some("proposer note".to_string()), + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key1, + 2, + 0, + &signer1, + platform_version, + None, + ) + .expect("expected to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let action_id = TokenBurnTransition::calculate_action_id_with_fields( + token_id.as_bytes(), + identity1.id().as_bytes(), + 2, + 1500, + ); + + // Confirmer attaches a note -> reject. + let confirm_with_note = BatchTransition::new_token_burn_transition( + token_id, + identity2.id(), + contract.id(), + 0, + 1500, + Some("confirmer note not allowed".to_string()), + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &key2, + 2, + 0, + &signer2, + platform_version, + None, + ) + .expect("expected to create confirm transition"); + + let serialized = confirm_with_note + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::TokenNoteOnlyAllowedWhenProposerError(_)) + )] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Proposer balance still intact; action still pending. + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity1.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(100000)); + } + + #[test] + fn test_token_burn_group_confirmer_modifies_amount_fails() { + // Confirmer attempts to burn a different amount than the proposer -> modification + // of main parameters is rejected. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity1, signer1, key1) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity2, signer2, key2) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity1.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(identity1.id(), 1), (identity2.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + // 1. Proposer for burn_amount 2000 + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity1.id(), + contract.id(), + 0, + 2000, + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key1, + 2, + 0, + &signer1, + platform_version, + None, + ) + .expect("expected to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // 2. Confirmer uses the proposer's action_id (2000) but submits burn_amount 3000 + let action_id = TokenBurnTransition::calculate_action_id_with_fields( + token_id.as_bytes(), + identity1.id().as_bytes(), + 2, + 2000, + ); + + let mismatched_confirm = BatchTransition::new_token_burn_transition( + token_id, + identity2.id(), + contract.id(), + 0, + 3000, // different from proposer + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &key2, + 2, + 0, + &signer2, + platform_version, + None, + ) + .expect("expected to create confirm transition"); + + let serialized = mismatched_confirm + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError( + StateError::ModificationOfGroupActionMainParametersNotPermittedError(_) + ), + .. + }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Balance still unaffected. + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity1.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(100000)); + } + + #[test] + fn test_token_burn_by_main_group_rule() { + // Verify the MainGroup authorization path: the rule says MainGroup, and the + // main_control_group is set to 0. A member of group 0 proposes the burn. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity1, signer1, key1) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (identity2, signer2, key2) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity1.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_main_control_group(Some(0)); + token_configuration.set_manual_burning_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::MainGroup, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(identity1.id(), 1), (identity2.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + // Proposer + let burn_transition = BatchTransition::new_token_burn_transition( + token_id, + identity1.id(), + contract.id(), + 0, + 500, + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key1, + 2, + 0, + &signer1, + platform_version, + None, + ) + .expect("expected to create burn transition"); + + let serialized = burn_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Confirmer + let action_id = TokenBurnTransition::calculate_action_id_with_fields( + token_id.as_bytes(), + identity1.id().as_bytes(), + 2, + 500, + ); + + let confirm_transition = BatchTransition::new_token_burn_transition( + token_id, + identity2.id(), + contract.id(), + 0, + 500, + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &key2, + 2, + 0, + &signer2, + platform_version, + None, + ) + .expect("expected to create confirm transition"); + + let serialized = confirm_transition + .serialize_to_bytes() + .expect("expected to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity1.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(99500)); + + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(99500)); + } + + #[test] + fn test_token_burn_after_full_depletion_fails_with_insufficient_balance() { + // Burn entire balance, then try to burn more -> insufficient balance. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + None::, + None, + None, + None, + platform_version, + ); + + // Burn full balance + let burn_all = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 100000, + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_all + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Second burn at zero balance -> error. + let burn_again = BatchTransition::new_token_burn_transition( + token_id, + identity.id(), + contract.id(), + 0, + 1, + None, + None, + &key, + 3, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn_again + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError( + StateError::IdentityDoesNotHaveEnoughTokenBalanceError(_) + ), + .. + }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(balance, Some(0)); + + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(0)); + } + + #[test] + fn test_token_burn_by_one_then_by_another_both_independent() { + // Two identities both authorized via ContractOwner + Identity rules? The + // AuthorizedActionTakers enum only has a single variant per rule, so we + // pick Identity() for a second holder and verify bursts from distinct + // identities decrement their own balances independently. + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (owner, owner_signer, owner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + None::, + None, + None, + None, + platform_version, + ); + + // Seed a second identity with a balance. Default rule only allows contract + // owner to burn, so the second identity only *holds* tokens here; only the + // owner does burns — but the burn targets the owner's own balance. + let (holder, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + add_tokens_to_identity(&platform, token_id, holder.id(), 7000); + + // Burn from owner (allowed). + let burn1 = BatchTransition::new_token_burn_transition( + token_id, + owner.id(), + contract.id(), + 0, + 4000, + None, + None, + &owner_key, + 2, + 0, + &owner_signer, + platform_version, + None, + ) + .expect("expect to create burn transition"); + + let serialized = burn1.serialize_to_bytes().expect("expected serialized"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // The holder's balance is untouched. + let holder_balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + holder.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(holder_balance, Some(7000)); + + // Owner's balance reduced. + let owner_balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + owner.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch balance"); + assert_eq!(owner_balance, Some(96000)); + + // Total supply: 100000 + 7000 - 4000 = 103000 + let total_supply = platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(total_supply, Some(103000)); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mint_many/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mint_many/mod.rs new file mode 100644 index 00000000000..70c582d3043 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mint_many/mod.rs @@ -0,0 +1,5 @@ +// TokenMintMany is not reachable via a user-facing state transition today — +// it's used internally by the distribution machinery as a TokenOperationType. +// Drive-level coverage for token_mint_many_v0, token_mint_many_add_to_operations_v0, +// and token_mint_many_operations_v0 lives as unit tests in +// packages/rs-drive/src/drive/tokens/mint_many/v0/mod.rs. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs index b2f52e56f4a..31901763afd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs @@ -7,7 +7,9 @@ mod distribution; mod emergency_action; mod freeze; mod mint; +mod mint_many; mod transfer; +mod unfreeze; use super::*; use crate::execution::validation::state_transition::tests::create_token_contract_with_owner_identity; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/unfreeze/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/unfreeze/mod.rs new file mode 100644 index 00000000000..e07e013df08 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/unfreeze/mod.rs @@ -0,0 +1,1857 @@ +use super::*; +mod token_unfreeze_tests { + use super::*; + use dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; + + mod token_unfreeze_basic_tests { + use super::*; + + // ────────────────────────────────────────────────────────── + // Successfully unfreeze a previously-frozen identity's balance. + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_previously_frozen_identity() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (identity_2, _, _) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_freeze_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_unfreeze_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, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + // First freeze identity_2 + let freeze_transition = BatchTransition::new_token_freeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create freeze transition"); + + let freeze_serialized = freeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[freeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Confirm frozen before unfreezing + let token_frozen = platform + .drive + .fetch_identity_token_info( + token_id.to_buffer(), + identity_2.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token info") + .map(|info| info.frozen()); + assert_eq!(token_frozen, Some(true)); + + // Now unfreeze + let unfreeze_transition = BatchTransition::new_token_unfreeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 3, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + + let unfreeze_serialized = unfreeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let token_frozen = platform + .drive + .fetch_identity_token_info( + token_id.to_buffer(), + identity_2.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token info") + .map(|info| info.frozen()); + assert_eq!(token_frozen, Some(false)); + } + + // ────────────────────────────────────────────────────────── + // Unfreeze when the identity was never frozen → Error. + // The validator returns IdentityTokenAccountNotFrozenError. + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_never_frozen_identity_fails() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (identity_2, _, _) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_unfreeze_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, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + let unfreeze_transition = BatchTransition::new_token_unfreeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + + let unfreeze_serialized = unfreeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError( + StateError::IdentityTokenAccountNotFrozenError(_) + ), + .. + }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // State must not have been mutated. + let token_frozen = platform + .drive + .fetch_identity_token_info( + token_id.to_buffer(), + identity_2.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token info") + .map(|info| info.frozen()); + assert_eq!(token_frozen, None); + } + + // ────────────────────────────────────────────────────────── + // Unfreezing an already-unfrozen identity → Error + // (no-op not allowed by the spec). + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_already_unfrozen_fails() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (identity_2, _, _) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_freeze_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_unfreeze_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, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + // freeze, then unfreeze, then try to unfreeze again + let freeze_transition = BatchTransition::new_token_freeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create freeze transition"); + + let freeze_serialized = freeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[freeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let unfreeze_transition = BatchTransition::new_token_unfreeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 3, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + + let unfreeze_serialized = unfreeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Second unfreeze should now fail. + let second_unfreeze = BatchTransition::new_token_unfreeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 4, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + + let second_unfreeze_serialized = second_unfreeze + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[second_unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError( + StateError::IdentityTokenAccountNotFrozenError(_) + ), + .. + }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + } + + // ────────────────────────────────────────────────────────── + // A non-authorized identity cannot unfreeze. + // Unfreeze is restricted to the contract owner, and a second + // identity tries to unfreeze → UnauthorizedTokenActionError. + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_non_authorized_identity_fails() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + + let platform_state = platform.state.load(); + + let (owner, owner_signer, owner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (identity_2, _, _) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (stranger, stranger_signer, stranger_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_freeze_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_unfreeze_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, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + // Owner freezes identity_2 first + let freeze_transition = BatchTransition::new_token_freeze_transition( + token_id, + owner.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &owner_key, + 2, + 0, + &owner_signer, + platform_version, + None, + ) + .expect("expect to create freeze transition"); + + let freeze_serialized = freeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[freeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // stranger tries to unfreeze + let unfreeze_transition = BatchTransition::new_token_unfreeze_transition( + token_id, + stranger.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &stranger_key, + 2, + 0, + &stranger_signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + + let unfreeze_serialized = unfreeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::UnauthorizedTokenActionError(_)), + .. + }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // identity_2 must remain frozen — unauthorized action must not mutate state. + let token_frozen = platform + .drive + .fetch_identity_token_info( + token_id.to_buffer(), + identity_2.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token info") + .map(|info| info.frozen()); + assert_eq!(token_frozen, Some(true)); + } + + // ────────────────────────────────────────────────────────── + // After unfreezing, a previously-frozen account can send tokens + // (verifying unfrozen state is truly effective). + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_allows_subsequent_transfer() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (identity_2, signer2, key2) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_freeze_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_unfreeze_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, + }, + )); + }), + None, + None, + None, + platform_version, + ); + + // Transfer tokens to identity_2 first. + let transfer_in = BatchTransition::new_token_transfer_transition( + token_id, + identity.id(), + contract.id(), + 0, + 7000, + identity_2.id(), + None, + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create transfer transition"); + + let transfer_serialized = transfer_in + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transfer_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Freeze identity_2. + let freeze_transition = BatchTransition::new_token_freeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 3, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create freeze transition"); + + let freeze_serialized = freeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[freeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Confirm send-while-frozen fails. + let blocked_send = BatchTransition::new_token_transfer_transition( + token_id, + identity_2.id(), + contract.id(), + 0, + 100, + identity.id(), + None, + None, + None, + &key2, + 2, + 0, + &signer2, + platform_version, + None, + ) + .expect("expect to create transfer transition"); + + let blocked_send_serialized = blocked_send + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[blocked_send_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::IdentityTokenAccountFrozenError( + _ + )), + .. + }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Unfreeze. + let unfreeze_transition = BatchTransition::new_token_unfreeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 4, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + + let unfreeze_serialized = unfreeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Transfer now succeeds. + let allowed_send = BatchTransition::new_token_transfer_transition( + token_id, + identity_2.id(), + contract.id(), + 0, + 250, + identity.id(), + None, + None, + None, + &key2, + 3, + 0, + &signer2, + platform_version, + None, + ) + .expect("expect to create transfer transition"); + + let allowed_send_serialized = allowed_send + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[allowed_send_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Balances reflect: identity started 100000, sent 7000 out, received 250. + let balance_identity = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token balance"); + assert_eq!(balance_identity, Some(100000 - 7000 + 250)); + + let balance_identity_2 = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity_2.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token balance"); + assert_eq!(balance_identity_2, Some(7000 - 250)); + } + + // ────────────────────────────────────────────────────────── + // Unfreeze authorized by a specific Identity (not ContractOwner). + // A dedicated "unfreezer" identity is the only party allowed to + // unfreeze. This exercises the Identity(...) branch of + // AuthorizedActionTakers during unfreeze. + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_authorized_identity() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (owner, owner_signer, owner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (unfreezer, unfreezer_signer, unfreezer_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (target, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let unfreezer_id = unfreezer.id(); + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + Some(move |token_configuration: &mut TokenConfiguration| { + token_configuration.set_freeze_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + ..Default::default() + }, + )); + // Only the specific `unfreezer` identity may unfreeze. + token_configuration.set_unfreeze_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Identity( + unfreezer_id, + ), + admin_action_takers: AuthorizedActionTakers::NoOne, + ..Default::default() + }, + )); + }), + None, + None, + None, + platform_version, + ); + + // Owner freezes target (allowed because freeze_rules = ContractOwner). + let freeze_transition = BatchTransition::new_token_freeze_transition( + token_id, + owner.id(), + contract.id(), + 0, + target.id(), + None, + None, + &owner_key, + 2, + 0, + &owner_signer, + platform_version, + None, + ) + .expect("expect to create freeze transition"); + let freeze_serialized = freeze_transition.serialize_to_bytes().unwrap(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[freeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .unwrap(); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + // Owner tries to unfreeze → rejected (owner isn't the authorized identity). + let owner_unfreeze = BatchTransition::new_token_unfreeze_transition( + token_id, + owner.id(), + contract.id(), + 0, + target.id(), + None, + None, + &owner_key, + 3, + 0, + &owner_signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + let owner_unfreeze_serialized = owner_unfreeze.serialize_to_bytes().unwrap(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[owner_unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .unwrap(); + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::UnauthorizedTokenActionError(_)), + .. + }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + // Unfreezer identity now unfreezes — this should succeed. + let unfreeze_transition = BatchTransition::new_token_unfreeze_transition( + token_id, + unfreezer.id(), + contract.id(), + 0, + target.id(), + None, + None, + &unfreezer_key, + 2, + 0, + &unfreezer_signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + let unfreeze_serialized = unfreeze_transition.serialize_to_bytes().unwrap(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .unwrap(); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + let token_frozen = platform + .drive + .fetch_identity_token_info( + token_id.to_buffer(), + target.id().to_buffer(), + None, + platform_version, + ) + .unwrap() + .map(|i| i.frozen()); + assert_eq!(token_frozen, Some(false)); + } + + // ────────────────────────────────────────────────────────── + // Unfreeze with an attached public note. + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_with_public_note() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (identity_2, _, _) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|cfg: &mut TokenConfiguration| { + cfg.set_freeze_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + ..Default::default() + })); + cfg.set_unfreeze_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + ..Default::default() + })); + }), + None, + None, + None, + platform_version, + ); + + // freeze first + let freeze_transition = BatchTransition::new_token_freeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create freeze transition"); + let freeze_serialized = freeze_transition.serialize_to_bytes().unwrap(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[freeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .unwrap(); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + // unfreeze with a public note + let unfreeze_transition = BatchTransition::new_token_unfreeze_transition( + token_id, + identity.id(), + contract.id(), + 0, + identity_2.id(), + Some("Thawing the account per dispute resolution".to_string()), + None, + &key, + 3, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + + let unfreeze_serialized = unfreeze_transition.serialize_to_bytes().unwrap(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + let token_frozen = platform + .drive + .fetch_identity_token_info( + token_id.to_buffer(), + identity_2.id().to_buffer(), + None, + platform_version, + ) + .unwrap() + .map(|i| i.frozen()); + assert_eq!(token_frozen, Some(false)); + } + + // ────────────────────────────────────────────────────────── + // Unfreeze against a non-existent contract → Error. + // This confirms the contract lookup path rejects unknown contracts + // before hitting unfreeze logic. + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_nonexistent_contract_fails() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (identity_2, _, _) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + // Random token / contract identifiers that were never created. + let fake_contract_id = Identifier::random_with_rng(&mut rng); + let fake_token_id = Identifier::random_with_rng(&mut rng); + + let unfreeze_transition = BatchTransition::new_token_unfreeze_transition( + fake_token_id, + identity.id(), + fake_contract_id, + 0, + identity_2.id(), + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("expect to create unfreeze transition"); + + let unfreeze_serialized = unfreeze_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + // The only invariant we strictly require is that this is NOT a successful execution: + // the validator should reject either because the contract is missing or the token is not frozen. + assert!(!matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + )); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + } + } + + mod token_unfreeze_group_tests { + use super::*; + use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Setters; + use dpp::group::group_action_status::GroupActionStatus; + use dpp::state_transition::batch_transition::TokenUnfreezeTransition; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::tokens::info::v0::IdentityTokenInfoV0; + use dpp::tokens::info::IdentityTokenInfo; + use drive::drive::Drive; + + // ────────────────────────────────────────────────────────── + // Owner alone does NOT have enough group power → unfreeze fails + // with UnauthorizedTokenActionError. + // ────────────────────────────────────────────────────────── + #[test] + fn test_token_unfreeze_owner_not_authorized_group_required() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(42); + let platform_state = platform.state.load(); + + let (owner, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (member, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (target, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + // Contract where freeze by contract owner is allowed, but unfreeze + // requires a 2-of-2 group. + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + Some(|token_cfg: &mut TokenConfiguration| { + token_cfg.set_freeze_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + ..Default::default() + })); + token_cfg.set_unfreeze_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + ..Default::default() + })); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(owner.id(), 1), (member.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + // Freeze target first using owner. + let freeze = BatchTransition::new_token_freeze_transition( + token_id, + owner.id(), + contract.id(), + 0, + target.id(), + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .expect("create freeze"); + + let freeze_ser = freeze.serialize_to_bytes().unwrap(); + let tx = platform.drive.grove.start_transaction(); + let res = platform + .platform + .process_raw_state_transitions( + &[freeze_ser], + &platform_state, + &BlockInfo::default(), + &tx, + platform_version, + false, + None, + ) + .unwrap(); + assert_matches!( + res.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .unwrap(); + + // Owner proposes unfreeze outside of a group action → unauthorized. + let unfreeze = BatchTransition::new_token_unfreeze_transition( + token_id, + owner.id(), + contract.id(), + 0, + target.id(), + None, + None, + &key, + 3, + 0, + &signer, + platform_version, + None, + ) + .expect("create unfreeze"); + + let unfreeze_ser = unfreeze.serialize_to_bytes().unwrap(); + let tx = platform.drive.grove.start_transaction(); + let res = platform + .platform + .process_raw_state_transitions( + &[unfreeze_ser], + &platform_state, + &BlockInfo::default(), + &tx, + platform_version, + false, + None, + ) + .unwrap(); + + assert_matches!( + res.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::UnauthorizedTokenActionError(_)), + .. + }] + ); + + platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .unwrap(); + + // target stays frozen. + let frozen = platform + .drive + .fetch_identity_token_info( + token_id.to_buffer(), + target.id().to_buffer(), + None, + platform_version, + ) + .unwrap() + .map(|i| i.frozen()); + assert_eq!(frozen, Some(true)); + } + + #[test] + fn test_token_unfreeze_two_member_group_no_keeping_history() { + test_token_unfreeze_two_member_group_with_keeps_history(false); + } + + #[test] + fn test_token_unfreeze_two_member_group_keeping_history() { + test_token_unfreeze_two_member_group_with_keeps_history(true); + } + + // ────────────────────────────────────────────────────────── + // Two‑signer scenario: proposer + second member complete unfreeze. + // Verifies GroupStateTransitionInfo flow for unfreeze. + // ────────────────────────────────────────────────────────── + fn test_token_unfreeze_two_member_group_with_keeps_history(keeps_freezing_history: bool) { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(44); + let platform_state = platform.state.load(); + + let (id1, sign1, key1) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (id2, sign2, key2) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (target, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + // Contract where both freeze and unfreeze require Group(0). + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + id1.id(), + Some(|cfg: &mut TokenConfiguration| { + cfg.keeps_history_mut() + .set_keeps_freezing_history(keeps_freezing_history); + cfg.set_freeze_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + ..Default::default() + })); + cfg.set_unfreeze_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + ..Default::default() + })); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(id1.id(), 1), (id2.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + // Contract owner freezes target first (allowed directly). + let freeze = BatchTransition::new_token_freeze_transition( + token_id, + id1.id(), + contract.id(), + 0, + target.id(), + None, + None, + &key1, + 2, + 0, + &sign1, + platform_version, + None, + ) + .expect("create freeze"); + let freeze_ser = freeze.serialize_to_bytes().unwrap(); + let tx = platform.drive.grove.start_transaction(); + let res = platform + .platform + .process_raw_state_transitions( + &[freeze_ser], + &platform_state, + &BlockInfo::default(), + &tx, + platform_version, + false, + None, + ) + .unwrap(); + assert_matches!( + res.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .unwrap(); + + // Proposer: id1 starts the group unfreeze action. + let unfreeze_propose = BatchTransition::new_token_unfreeze_transition( + token_id, + id1.id(), + contract.id(), + 0, + target.id(), + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key1, + 3, + 0, + &sign1, + platform_version, + None, + ) + .expect("expect to create batch transition"); + + let unfreeze_propose_ser = unfreeze_propose + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_propose_ser], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Prove & verify the proposer transition. + let proof = platform + .drive + .prove_state_transition(&unfreeze_propose, None, platform_version) + .expect("expect to prove state transition"); + let (_root_hash, result) = Drive::verify_state_transition_was_executed_with_proof( + &unfreeze_propose, + &BlockInfo::default(), + proof.data.as_ref().expect("expected data"), + &|_| Ok(Some(contract.clone().into())), + platform_version, + ) + .unwrap_or_else(|e| { + panic!( + "expect to verify state transition proof {}, error is {}", + hex::encode(proof.data.expect("expected data")), + e + ) + }); + + if keeps_freezing_history { + assert_matches!( + result, + StateTransitionProofResult::VerifiedTokenGroupActionWithDocument(power, doc) => { + assert_eq!(power, 1); + assert_eq!(doc, None); + } + ); + } else { + assert_matches!( + result, + StateTransitionProofResult::VerifiedTokenGroupActionWithTokenIdentityInfo(power, status, info) => { + assert_eq!(power, 1); + assert_eq!(status, GroupActionStatus::ActionActive); + // mid-action, state has not yet been flipped. + assert_eq!(info, Some(IdentityTokenInfo::V0(IdentityTokenInfoV0 { frozen: true }))); + } + ); + } + + // Second signer completes the group action. + let action_id = TokenUnfreezeTransition::calculate_action_id_with_fields( + token_id.as_bytes(), + id1.id().as_bytes(), + 3, + target.id().as_bytes(), + ); + + let unfreeze_confirm = BatchTransition::new_token_unfreeze_transition( + token_id, + id2.id(), + contract.id(), + 0, + target.id(), + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &key2, + 2, + 0, + &sign2, + platform_version, + None, + ) + .unwrap(); + + let unfreeze_confirm_ser = unfreeze_confirm + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[unfreeze_confirm_ser], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Prove & verify the confirmation. + let proof = platform + .drive + .prove_state_transition(&unfreeze_confirm, None, platform_version) + .expect("expect to prove state transition"); + let (_root_hash, result) = Drive::verify_state_transition_was_executed_with_proof( + &unfreeze_confirm, + &BlockInfo::default(), + proof.data.as_ref().expect("expected data"), + &|_| Ok(Some(contract.clone().into())), + platform_version, + ) + .unwrap_or_else(|e| { + panic!( + "expect to verify state transition proof {}, error is {}", + hex::encode(proof.data.expect("expected data")), + e + ) + }); + + if keeps_freezing_history { + assert_matches!( + result, + StateTransitionProofResult::VerifiedTokenGroupActionWithDocument(power, doc) => { + assert_eq!(power, 2); + assert_eq!(doc.expect("expected document").properties().get_identifier("frozenIdentityId").expect("expected frozen id"), target.id()); + } + ); + } else { + assert_matches!( + result, + StateTransitionProofResult::VerifiedTokenGroupActionWithTokenIdentityInfo(power, status, info) => { + assert_eq!(power, 2); + assert_eq!(status, GroupActionStatus::ActionClosed); + // After group action completes, the target is no longer frozen. + assert_eq!(info, Some(IdentityTokenInfo::V0(IdentityTokenInfoV0 { frozen: false }))); + } + ); + } + + // Verify target is unfrozen on-chain. + let frozen = platform + .drive + .fetch_identity_token_info( + token_id.to_buffer(), + target.id().to_buffer(), + None, + platform_version, + ) + .unwrap() + .map(|i| i.frozen()); + assert_eq!(frozen, Some(false)); + } + } +} diff --git a/packages/rs-drive/src/drive/tokens/info/fetch_identities_token_infos/v0/mod.rs b/packages/rs-drive/src/drive/tokens/info/fetch_identities_token_infos/v0/mod.rs index e892dcd7aaf..0ddf711bcc0 100644 --- a/packages/rs-drive/src/drive/tokens/info/fetch_identities_token_infos/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/info/fetch_identities_token_infos/v0/mod.rs @@ -64,3 +64,253 @@ impl Drive { .collect() } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v1::DataContractV1Getters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::v1::DataContractV1; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::Identity; + use dpp::prelude::DataContract; + use dpp::tokens::info::v0::IdentityTokenInfoV0; + use dpp::tokens::info::IdentityTokenInfo; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + fn build_single_token_contract() -> DataContract { + DataContract::V1(DataContractV1 { + id: Default::default(), + version: 0, + owner_id: Default::default(), + document_types: Default::default(), + config: DataContractConfig::V0(DataContractConfigV0 { + can_be_deleted: false, + readonly: false, + keeps_history: false, + documents_keep_history_contract_default: false, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: None, + requires_identity_decryption_bounded_key: None, + }), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([( + 0, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + )]), + keywords: Vec::new(), + description: None, + }) + } + + #[test] + fn should_return_empty_for_empty_identity_id_list() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let token_id = [3u8; 32]; + + let infos = drive + .fetch_identities_token_infos_v0(token_id, &[], None, platform_version) + .expect("expected fetch with empty identity list to succeed"); + + assert!( + infos.is_empty(), + "expected empty map for empty identity list, got {:?}", + infos + ); + } + + #[test] + fn should_return_partial_hits_across_multiple_identities() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let identity_frozen = Identity::random_identity(3, Some(20), platform_version) + .expect("expected a platform identity"); + let identity_frozen_id = identity_frozen.id().to_buffer(); + + let identity_other = Identity::random_identity(3, Some(21), platform_version) + .expect("expected a platform identity"); + let identity_other_id = identity_other.id().to_buffer(); + + let contract = build_single_token_contract(); + let token_id = contract.token_id(0).expect("expected token at position 0"); + + drive + .add_new_identity( + identity_frozen.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add identity_frozen"); + drive + .add_new_identity( + identity_other.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add identity_other"); + + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert contract"); + + drive + .token_freeze( + token_id, + identity_frozen.id(), + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to freeze first identity"); + + let infos = drive + .fetch_identities_token_infos_v0( + token_id.to_buffer(), + &[identity_frozen_id, identity_other_id], + None, + platform_version, + ) + .expect("expected fetch to succeed"); + + assert_eq!( + infos, + BTreeMap::from([ + ( + identity_frozen_id, + Some(IdentityTokenInfo::V0(IdentityTokenInfoV0 { frozen: true })), + ), + (identity_other_id, None), + ]) + ); + } + + #[test] + fn should_return_none_entries_for_non_existent_token() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // No contract/token ever inserted for this token_id + let token_id = [123u8; 32]; + let identity_ids = [[1u8; 32], [2u8; 32], [3u8; 32]]; + + let infos = drive + .fetch_identities_token_infos_v0(token_id, &identity_ids, None, platform_version) + .expect("expected fetch to succeed even when token tree does not exist"); + + for (_, v) in infos.iter() { + assert!( + v.is_none(), + "expected all values to be None for non-existent token, got {:?}", + infos + ); + } + } + + #[test] + fn should_return_costs_for_fetch_with_costs() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + + let identity_frozen = Identity::random_identity(3, Some(22), platform_version) + .expect("expected a platform identity"); + let identity_frozen_id = identity_frozen.id().to_buffer(); + + let identity_other = Identity::random_identity(3, Some(23), platform_version) + .expect("expected a platform identity"); + let identity_other_id = identity_other.id().to_buffer(); + + let contract = build_single_token_contract(); + let token_id = contract.token_id(0).expect("expected token at position 0"); + + drive + .add_new_identity( + identity_frozen.clone(), + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to add identity_frozen"); + drive + .add_new_identity( + identity_other.clone(), + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to add identity_other"); + + drive + .insert_contract(&contract, block_info, true, None, platform_version) + .expect("expected to insert contract"); + + drive + .token_freeze( + token_id, + identity_frozen.id(), + &block_info, + true, + None, + platform_version, + ) + .expect("expected to freeze first identity"); + + let (infos, fees) = drive + .fetch_identities_token_infos_with_costs( + token_id.to_buffer(), + &[identity_frozen_id, identity_other_id], + &block_info, + None, + platform_version, + ) + .expect("expected fetch with costs to succeed"); + + assert_eq!( + infos, + BTreeMap::from([ + ( + identity_frozen_id, + Some(IdentityTokenInfo::V0(IdentityTokenInfoV0 { frozen: true })), + ), + (identity_other_id, None), + ]) + ); + assert!( + fees.processing_fee > 0 || fees.storage_fee > 0, + "expected non-zero fees for a fetch-with-costs call" + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_info/v0/mod.rs b/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_info/v0/mod.rs index 241b65e4997..e4a5f19539f 100644 --- a/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_info/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_info/v0/mod.rs @@ -74,3 +74,219 @@ impl Drive { } } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v1::DataContractV1Getters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::v1::DataContractV1; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::Identity; + use dpp::prelude::DataContract; + use dpp::tokens::info::v0::IdentityTokenInfoV0; + use dpp::tokens::info::IdentityTokenInfo; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + /// Build a simple single-token data contract for tests. + fn build_single_token_contract() -> DataContract { + DataContract::V1(DataContractV1 { + id: Default::default(), + version: 0, + owner_id: Default::default(), + document_types: Default::default(), + config: DataContractConfig::V0(DataContractConfigV0 { + can_be_deleted: false, + readonly: false, + keeps_history: false, + documents_keep_history_contract_default: false, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: None, + requires_identity_decryption_bounded_key: None, + }), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([( + 0, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + )]), + keywords: Vec::new(), + description: None, + }) + } + + #[test] + fn should_return_none_for_non_existent_token() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // token_id that has never been created => path-key-not-found should collapse to None + let token_id = [77u8; 32]; + let identity_id = [88u8; 32]; + + let info = drive + .fetch_identity_token_info_v0(token_id, identity_id, None, platform_version) + .expect("expected fetch to succeed (non-existent token => None)"); + + assert_eq!(info, None); + } + + #[test] + fn should_return_none_for_identity_without_info_record() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Real contract/token exists, but the identity has never had an info record + // (never frozen/unfrozen). Expect None from the fetch. + let contract = build_single_token_contract(); + let token_id = contract + .token_id(0) + .expect("expected token at position 0") + .to_buffer(); + + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert contract"); + + // Arbitrary identity id that has never touched this token + let identity_id = [42u8; 32]; + + let info = drive + .fetch_identity_token_info_v0(token_id, identity_id, None, platform_version) + .expect("expected fetch to succeed"); + + assert_eq!(info, None); + } + + #[test] + fn should_return_frozen_info_after_freeze() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let identity = Identity::random_identity(3, Some(5), platform_version) + .expect("expected a platform identity"); + let identity_id = identity.id().to_buffer(); + + let contract = build_single_token_contract(); + let token_id = contract.token_id(0).expect("expected token at position 0"); + + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add an identity"); + + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert contract"); + + drive + .token_freeze( + token_id, + identity.id(), + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to freeze token"); + + let info = drive + .fetch_identity_token_info_v0(token_id.to_buffer(), identity_id, None, platform_version) + .expect("expected fetch to succeed"); + + assert_eq!( + info, + Some(IdentityTokenInfo::V0(IdentityTokenInfoV0 { frozen: true })) + ); + } + + #[test] + fn should_return_info_with_costs_after_freeze() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + + let identity = Identity::random_identity(3, Some(6), platform_version) + .expect("expected a platform identity"); + let identity_id = identity.id().to_buffer(); + + let contract = build_single_token_contract(); + let token_id = contract.token_id(0).expect("expected token at position 0"); + + drive + .add_new_identity( + identity.clone(), + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to add an identity"); + + drive + .insert_contract(&contract, block_info, true, None, platform_version) + .expect("expected to insert contract"); + + drive + .token_freeze( + token_id, + identity.id(), + &block_info, + true, + None, + platform_version, + ) + .expect("expected to freeze token"); + + let (info, fees) = drive + .fetch_identity_token_info_with_costs( + token_id.to_buffer(), + identity_id, + &block_info, + true, + None, + platform_version, + ) + .expect("expected fetch with costs to succeed"); + + assert_eq!( + info, + Some(IdentityTokenInfo::V0(IdentityTokenInfoV0 { frozen: true })) + ); + assert!( + fees.processing_fee > 0 || fees.storage_fee > 0, + "expected non-zero fees for a stateful fetch" + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_infos/v0/mod.rs b/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_infos/v0/mod.rs index 21ab3474ba6..4d1f9d6cc6d 100644 --- a/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_infos/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_infos/v0/mod.rs @@ -71,3 +71,237 @@ impl Drive { .collect() } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v1::DataContractV1Getters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::v1::DataContractV1; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::Identity; + use dpp::prelude::DataContract; + use dpp::tokens::info::v0::IdentityTokenInfoV0; + use dpp::tokens::info::IdentityTokenInfo; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + /// Build a contract with two tokens at positions 0 and 1. + fn build_two_token_contract() -> DataContract { + DataContract::V1(DataContractV1 { + id: Default::default(), + version: 0, + owner_id: Default::default(), + document_types: Default::default(), + config: DataContractConfig::V0(DataContractConfigV0 { + can_be_deleted: false, + readonly: false, + keeps_history: false, + documents_keep_history_contract_default: false, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: None, + requires_identity_decryption_bounded_key: None, + }), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([ + ( + 0, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + ), + ( + 1, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + ), + ]), + keywords: Vec::new(), + description: None, + }) + } + + #[test] + fn should_return_empty_for_empty_token_id_list() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let identity_id = [1u8; 32]; + + let infos = drive + .fetch_identity_token_infos_v0(&[], identity_id, None, platform_version) + .expect("expected fetch with empty token list to succeed"); + + assert!( + infos.is_empty(), + "expected empty result map for empty token list, got {:?}", + infos + ); + } + + #[test] + fn should_return_partial_hits_across_multiple_tokens() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let identity = Identity::random_identity(3, Some(7), platform_version) + .expect("expected a platform identity"); + let identity_id = identity.id().to_buffer(); + + let contract = build_two_token_contract(); + let token_id_0 = contract.token_id(0).expect("expected token at position 0"); + let token_id_1 = contract.token_id(1).expect("expected token at position 1"); + + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add an identity"); + + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert contract"); + + // Only freeze the first token — the second should have no info record. + drive + .token_freeze( + token_id_0, + identity.id(), + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to freeze token 0"); + + let infos = drive + .fetch_identity_token_infos_v0( + &[token_id_0.to_buffer(), token_id_1.to_buffer()], + identity_id, + None, + platform_version, + ) + .expect("expected fetch to succeed"); + + assert_eq!( + infos, + BTreeMap::from([ + ( + token_id_0.to_buffer(), + Some(IdentityTokenInfo::V0(IdentityTokenInfoV0 { frozen: true })), + ), + (token_id_1.to_buffer(), None), + ]) + ); + } + + #[test] + fn should_return_none_entries_for_all_non_existent_tokens() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let identity_id = [9u8; 32]; + // Use token ids that have no on-disk info tree at all. + let token_ids = [[200u8; 32], [201u8; 32]]; + + let infos = drive + .fetch_identity_token_infos_v0(&token_ids, identity_id, None, platform_version) + .expect("expected fetch to succeed even for non-existent tokens"); + + // grove_get_raw_path_query_with_optional returns entries for each requested key + // even when nothing exists — they should all be None. + for (_, v) in infos.iter() { + assert!( + v.is_none(), + "expected every entry to be None, got {:?}", + infos + ); + } + } + + #[test] + fn should_return_costs_for_fetch_with_costs() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + + let identity = Identity::random_identity(3, Some(8), platform_version) + .expect("expected a platform identity"); + let identity_id = identity.id().to_buffer(); + + let contract = build_two_token_contract(); + let token_id_0 = contract.token_id(0).expect("expected token at position 0"); + let token_id_1 = contract.token_id(1).expect("expected token at position 1"); + + drive + .add_new_identity( + identity.clone(), + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to add an identity"); + + drive + .insert_contract(&contract, block_info, true, None, platform_version) + .expect("expected to insert contract"); + + drive + .token_freeze( + token_id_0, + identity.id(), + &block_info, + true, + None, + platform_version, + ) + .expect("expected to freeze token 0"); + + let (infos, fees) = drive + .fetch_identity_token_infos_with_costs( + &[token_id_0.to_buffer(), token_id_1.to_buffer()], + identity_id, + &block_info, + None, + platform_version, + ) + .expect("expected fetch with costs to succeed"); + + assert_eq!( + infos, + BTreeMap::from([ + ( + token_id_0.to_buffer(), + Some(IdentityTokenInfo::V0(IdentityTokenInfoV0 { frozen: true })), + ), + (token_id_1.to_buffer(), None), + ]) + ); + assert!( + fees.processing_fee > 0 || fees.storage_fee > 0, + "expected non-zero fees for a fetch-with-costs call" + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/info/queries.rs b/packages/rs-drive/src/drive/tokens/info/queries.rs index 50082114dd7..c30d922f41c 100644 --- a/packages/rs-drive/src/drive/tokens/info/queries.rs +++ b/packages/rs-drive/src/drive/tokens/info/queries.rs @@ -95,3 +95,209 @@ impl Drive { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::drive::tokens::paths::{ + token_identity_infos_path_vec, token_identity_infos_root_path_vec, + }; + + #[test] + fn token_info_for_identity_id_query_targets_correct_path_and_limit() { + let token_id = [1u8; 32]; + let identity_id = [2u8; 32]; + + let path_query = Drive::token_info_for_identity_id_query(token_id, identity_id); + + // Path must be the identity-infos subtree for this token. + assert_eq!(path_query.path, token_identity_infos_path_vec(token_id)); + // Must request exactly one item. + assert_eq!(path_query.query.limit, Some(1)); + // The underlying grovedb Query should target exactly the identity id key. + let keys = path_query.query.query.items; + assert_eq!(keys.len(), 1, "expected exactly one query item"); + // The item should be a Key variant whose inner bytes match identity_id. + match &keys[0] { + QueryItem::Key(k) => assert_eq!(k.as_slice(), identity_id.as_slice()), + other => panic!("expected QueryItem::Key(..), got {:?}", other), + } + } + + #[test] + fn token_infos_for_identity_ids_query_includes_all_keys_and_limit() { + let token_id = [3u8; 32]; + let identity_ids = [[10u8; 32], [11u8; 32], [12u8; 32]]; + + let path_query = Drive::token_infos_for_identity_ids_query(token_id, &identity_ids); + + assert_eq!(path_query.path, token_identity_infos_path_vec(token_id)); + assert_eq!(path_query.query.limit, Some(identity_ids.len() as u16)); + assert_eq!(path_query.query.offset, None); + + // Each identity id should be present as a Key item in the query. + let items = &path_query.query.query.items; + assert_eq!(items.len(), identity_ids.len()); + for id in identity_ids.iter() { + assert!( + items.iter().any(|item| match item { + QueryItem::Key(k) => k.as_slice() == id.as_slice(), + _ => false, + }), + "expected identity id {:?} in query items {:?}", + id, + items + ); + } + } + + #[test] + fn token_infos_for_identity_ids_query_empty_identity_list() { + let token_id = [4u8; 32]; + + let path_query = Drive::token_infos_for_identity_ids_query(token_id, &[]); + + assert_eq!(path_query.path, token_identity_infos_path_vec(token_id)); + assert_eq!(path_query.query.limit, Some(0)); + assert!(path_query.query.query.items.is_empty()); + } + + #[test] + fn token_infos_for_identity_id_query_uses_subquery_path() { + let token_ids = [[5u8; 32], [6u8; 32]]; + let identity_id = [7u8; 32]; + + let path_query = Drive::token_infos_for_identity_id_query(&token_ids, identity_id); + + // Root path is the overall identity-infos tree (not scoped to a token). + assert_eq!(path_query.path, token_identity_infos_root_path_vec()); + assert_eq!(path_query.query.limit, Some(token_ids.len() as u16)); + + // Default subquery path should be set to the identity id — this is what + // makes the query descend into each token's subtree to fetch that identity. + let expected_subquery_path = vec![identity_id.to_vec()]; + assert_eq!( + path_query.query.query.default_subquery_branch.subquery_path, + Some(expected_subquery_path) + ); + + // Each token id must appear as a Key item in the top-level query. + let items = &path_query.query.query.items; + assert_eq!(items.len(), token_ids.len()); + for id in token_ids.iter() { + assert!( + items.iter().any(|item| match item { + QueryItem::Key(k) => k.as_slice() == id.as_slice(), + _ => false, + }), + "expected token id {:?} in query items {:?}", + id, + items + ); + } + } + + #[test] + fn token_infos_for_range_query_ascending_with_no_start_uses_range_full() { + let token_id = [8u8; 32]; + let path_query = Drive::token_infos_for_range_query(token_id, None, true, 50); + + assert_eq!(path_query.path, token_identity_infos_path_vec(token_id)); + assert_eq!(path_query.query.limit, Some(50)); + + let items = &path_query.query.query.items; + assert_eq!(items.len(), 1); + assert!( + matches!(&items[0], QueryItem::RangeFull(_)), + "expected RangeFull when no start_at is provided, got {:?}", + items[0] + ); + } + + #[test] + fn token_infos_for_range_query_ascending_with_start_inclusive_uses_range_from() { + let token_id = [9u8; 32]; + let start_at = [1u8; 32]; + let path_query = + Drive::token_infos_for_range_query(token_id, Some((start_at, true)), true, 10); + + let items = &path_query.query.query.items; + assert_eq!(items.len(), 1); + match &items[0] { + QueryItem::RangeFrom(r) => assert_eq!(r.start.as_slice(), start_at.as_slice()), + other => panic!( + "expected RangeFrom when ascending+inclusive, got {:?}", + other + ), + } + } + + #[test] + fn token_infos_for_range_query_ascending_with_start_exclusive_uses_range_after() { + let token_id = [10u8; 32]; + let start_at = [2u8; 32]; + let path_query = + Drive::token_infos_for_range_query(token_id, Some((start_at, false)), true, 10); + + let items = &path_query.query.query.items; + assert_eq!(items.len(), 1); + match &items[0] { + QueryItem::RangeAfter(r) => assert_eq!(r.start.as_slice(), start_at.as_slice()), + other => panic!( + "expected RangeAfter when ascending+exclusive, got {:?}", + other + ), + } + } + + #[test] + fn token_infos_for_range_query_descending_with_start_inclusive_uses_range_to_inclusive() { + let token_id = [11u8; 32]; + let start_at = [3u8; 32]; + let path_query = + Drive::token_infos_for_range_query(token_id, Some((start_at, true)), false, 5); + + let items = &path_query.query.query.items; + assert_eq!(items.len(), 1); + match &items[0] { + QueryItem::RangeToInclusive(r) => assert_eq!(r.end.as_slice(), start_at.as_slice()), + other => panic!( + "expected RangeToInclusive when descending+inclusive, got {:?}", + other + ), + } + } + + #[test] + fn token_infos_for_range_query_descending_with_start_exclusive_uses_range_to() { + let token_id = [12u8; 32]; + let start_at = [4u8; 32]; + let path_query = + Drive::token_infos_for_range_query(token_id, Some((start_at, false)), false, 5); + + let items = &path_query.query.query.items; + assert_eq!(items.len(), 1); + match &items[0] { + QueryItem::RangeTo(r) => assert_eq!(r.end.as_slice(), start_at.as_slice()), + other => panic!( + "expected RangeTo when descending+exclusive, got {:?}", + other + ), + } + } + + #[test] + fn token_infos_for_range_query_descending_without_start_uses_range_full() { + let token_id = [13u8; 32]; + let path_query = Drive::token_infos_for_range_query(token_id, None, false, 100); + + assert_eq!(path_query.query.limit, Some(100)); + let items = &path_query.query.query.items; + assert_eq!(items.len(), 1); + assert!( + matches!(&items[0], QueryItem::RangeFull(_)), + "expected RangeFull when descending with no start_at, got {:?}", + items[0] + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/mint_many/v0/mod.rs b/packages/rs-drive/src/drive/tokens/mint_many/v0/mod.rs index a173b69e795..5f21a601d74 100644 --- a/packages/rs-drive/src/drive/tokens/mint_many/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/mint_many/v0/mod.rs @@ -148,3 +148,676 @@ impl Drive { Ok(drive_operations) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v1::DataContractV1Getters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::v1::DataContractV1; + use dpp::identifier::Identifier; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::Identity; + use dpp::prelude::DataContract; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + /// Helper that creates a drive with a fresh token contract containing a + /// single token at position 0, and returns the drive + token id. + fn setup_drive_with_token() -> (crate::drive::Drive, [u8; 32]) { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let contract = DataContract::V1(DataContractV1 { + id: Default::default(), + version: 0, + owner_id: Default::default(), + document_types: Default::default(), + config: DataContractConfig::V0(DataContractConfigV0 { + can_be_deleted: false, + readonly: false, + keeps_history: false, + documents_keep_history_contract_default: false, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: None, + requires_identity_decryption_bounded_key: None, + }), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([( + 0, + TokenConfiguration::V0( + TokenConfigurationV0::default_most_restrictive().with_base_supply(0), + ), + )]), + keywords: Vec::new(), + description: None, + }); + + let token_id = contract + .token_id(0) + .expect("expected token at position 0") + .to_buffer(); + + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert contract"); + + (drive, token_id) + } + + /// Inserts a fresh random identity and returns its id buffer. + fn insert_identity(drive: &crate::drive::Drive, seed: u64) -> [u8; 32] { + let platform_version = PlatformVersion::latest(); + let identity = Identity::random_identity(3, Some(seed), platform_version) + .expect("expected a platform identity"); + let id = identity.id().to_buffer(); + drive + .add_new_identity( + identity, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add identity"); + id + } + + #[test] + fn should_mint_many_to_multiple_recipients_with_proportional_weights() { + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 1); + let id_b = insert_identity(&drive, 2); + let id_c = insert_identity(&drive, 3); + + let total = 1000u64; + let recipients = vec![ + (Identifier::new(id_a), 1), + (Identifier::new(id_b), 2), + (Identifier::new(id_c), 2), + ]; + + drive + .token_mint_many_v0( + Identifier::new(token_id), + recipients, + total, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected mint_many to succeed"); + + let balance_a = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance a") + .expect("balance a exists"); + let balance_b = drive + .fetch_identity_token_balance(token_id, id_b, None, platform_version) + .expect("fetch balance b") + .expect("balance b exists"); + let balance_c = drive + .fetch_identity_token_balance(token_id, id_c, None, platform_version) + .expect("fetch balance c") + .expect("balance c exists"); + + // Weight sum is 5. First two recipients use div_ceil(amount*weight, 5). + // a: div_ceil(1000*1, 5) = 200, b: div_ceil(1000*2, 5) = 400, c: the + // remainder = 1000 - 200 - 400 = 400. + assert_eq!(balance_a, 200); + assert_eq!(balance_b, 400); + assert_eq!(balance_c, 400); + + // Confirm the total supply was updated. + let total_supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch total supply") + .expect("total supply present"); + assert_eq!(total_supply, total); + + // All issued tokens must be fully distributed. + assert_eq!(balance_a + balance_b + balance_c, total); + } + + #[test] + fn should_mint_many_to_single_recipient() { + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 10); + let recipients = vec![(Identifier::new(id_a), 100)]; + + drive + .token_mint_many_v0( + Identifier::new(token_id), + recipients, + 5000, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected mint_many to succeed"); + + let balance = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance") + .expect("balance present"); + assert_eq!(balance, 5000); + + let total_supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch total supply") + .expect("total supply present"); + assert_eq!(total_supply, 5000); + } + + #[test] + fn should_mint_many_when_some_recipients_have_weight_zero() { + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 20); + let id_b = insert_identity(&drive, 21); + let id_c = insert_identity(&drive, 22); + + // Weight sum of the non-zero entries is 10. B has weight 0 and should + // receive nothing. C is last, so it takes the remainder. + let total = 1000u64; + let recipients = vec![ + (Identifier::new(id_a), 3), + (Identifier::new(id_b), 0), + (Identifier::new(id_c), 7), + ]; + + drive + .token_mint_many_v0( + Identifier::new(token_id), + recipients, + total, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected mint_many to succeed"); + + let balance_a = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance a") + .expect("balance a exists"); + let balance_b = drive + .fetch_identity_token_balance(token_id, id_b, None, platform_version) + .expect("fetch balance b") + .expect("balance b exists"); + let balance_c = drive + .fetch_identity_token_balance(token_id, id_c, None, platform_version) + .expect("fetch balance c") + .expect("balance c exists"); + + // a = div_ceil(1000*3, 10) = 300, b = div_ceil(1000*0, 10) = 0, + // c (last) = 1000 - 300 - 0 = 700. + assert_eq!(balance_a, 300); + assert_eq!(balance_b, 0); + assert_eq!(balance_c, 700); + assert_eq!(balance_a + balance_b + balance_c, total); + } + + #[test] + fn should_clamp_recipient_weight_above_u32_max_to_u32_max() { + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 30); + let id_b = insert_identity(&drive, 31); + + // a's requested weight exceeds u32::MAX. Internally it must be + // clamped to u32::MAX, which means both recipients end up with equal + // effective weight so the split should be ~50/50 (remainder to last). + let total = 1000u64; + let huge_weight = (u32::MAX as u64) + 1_000_000; + let recipients = vec![ + (Identifier::new(id_a), huge_weight), + (Identifier::new(id_b), u32::MAX as u64), + ]; + + drive + .token_mint_many_v0( + Identifier::new(token_id), + recipients, + total, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected mint_many to succeed"); + + let balance_a = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance a") + .expect("balance a exists"); + let balance_b = drive + .fetch_identity_token_balance(token_id, id_b, None, platform_version) + .expect("fetch balance b") + .expect("balance b exists"); + + // Effective weight sum after clamp = 2 * u32::MAX. First recipient: + // div_ceil(1000 * u32::MAX, 2 * u32::MAX) = 500. Second (last) gets + // the remainder = 500. + assert_eq!(balance_a, 500); + assert_eq!(balance_b, 500); + assert_eq!(balance_a + balance_b, total); + } + + #[test] + fn should_update_total_supply_across_multiple_mint_many_calls() { + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 40); + let id_b = insert_identity(&drive, 41); + + // First mint creates the supply entry (allow_first_mint = true). + drive + .token_mint_many_v0( + Identifier::new(token_id), + vec![(Identifier::new(id_a), 1), (Identifier::new(id_b), 1)], + 200, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("first mint"); + + let after_first = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch total supply") + .expect("total supply present"); + assert_eq!(after_first, 200); + + // Subsequent mints should not require allow_first_mint because the + // supply entry already exists. + drive + .token_mint_many_v0( + Identifier::new(token_id), + vec![(Identifier::new(id_a), 3), (Identifier::new(id_b), 1)], + 400, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("second mint"); + + let after_second = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch total supply") + .expect("total supply present"); + assert_eq!(after_second, 600); + + // Balances should reflect the sum of the two mints. + // First mint: weight sum 2, total 200 -> a = 100, b = 100. + // Second mint: weight sum 4, total 400 -> a = div_ceil(400*3, 4) = 300, + // b (last) = 400 - 300 = 100. + let balance_a = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance a") + .expect("balance a exists"); + let balance_b = drive + .fetch_identity_token_balance(token_id, id_b, None, platform_version) + .expect("fetch balance b") + .expect("balance b exists"); + assert_eq!(balance_a, 400); + assert_eq!(balance_b, 200); + assert_eq!(balance_a + balance_b, after_second); + } + + #[test] + fn should_succeed_with_allow_first_mint_false_when_supply_already_exists() { + // A contract insert pre-creates the per-token total-supply entry. + // Once the entry exists, mint_many with allow_first_mint=false must + // still succeed because it's an "add to existing supply" call. + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 50); + + drive + .token_mint_many_v0( + Identifier::new(token_id), + vec![(Identifier::new(id_a), 1)], + 100, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("mint_many with allow_first_mint=false must succeed when supply exists"); + + let balance = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance") + .expect("balance present"); + assert_eq!(balance, 100); + } + + #[test] + fn should_mint_many_amount_zero_leaves_supply_and_balances_zero() { + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 60); + let id_b = insert_identity(&drive, 61); + + drive + .token_mint_many_v0( + Identifier::new(token_id), + vec![(Identifier::new(id_a), 1), (Identifier::new(id_b), 1)], + 0, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected mint_many of 0 to succeed"); + + let balance_a = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance a"); + let balance_b = drive + .fetch_identity_token_balance(token_id, id_b, None, platform_version) + .expect("fetch balance b"); + + // Minting zero should either leave the per-identity balance absent + // or set it to zero. Either is acceptable; a positive balance would + // be a bug. + assert!(balance_a.unwrap_or(0) == 0); + assert!(balance_b.unwrap_or(0) == 0); + + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch supply"); + assert!(supply.unwrap_or(0) == 0); + } + + #[test] + fn should_mint_many_when_recipient_identity_does_not_exist() { + // Drive-level mint_many does not verify that recipients are existing + // identities — that check is higher up the stack. The raw operation + // should still succeed and create the balance entry. + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let stranger = Identifier::new([0xAB; 32]); + + drive + .token_mint_many_v0( + Identifier::new(token_id), + vec![(stranger, 1)], + 777, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected mint_many to succeed even without a registered identity"); + + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch supply") + .expect("supply present"); + assert_eq!(supply, 777); + } + + #[test] + fn add_to_operations_v0_populates_drive_operations() { + // Exercises the token_mint_many_add_to_operations_v0 entry point + // directly (the v0 fn that takes an out-parameter for drive ops). + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 70); + + let mut drive_operations = Vec::new(); + drive + .token_mint_many_add_to_operations_v0( + Identifier::new(token_id), + vec![(Identifier::new(id_a), 1)], + 123, + true, + true, + None, + &mut drive_operations, + platform_version, + ) + .expect("expected add_to_operations to succeed"); + + // The call populates low-level drive operations that were applied. + assert!( + !drive_operations.is_empty(), + "expected drive_operations to be populated after applying the batch" + ); + + let balance = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance") + .expect("balance present"); + assert_eq!(balance, 123); + } + + #[test] + fn operations_v0_returns_ops_without_applying_state() { + // token_mint_many_operations_v0 returns the list of pending ops + // without applying them. With apply=false (None transaction) state + // must remain unchanged until apply_batch is called. + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 80); + + // Capture the supply as it exists immediately after contract insert + // (base_supply=0 means the entry exists at value 0). + let supply_before = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch supply before") + .unwrap_or(0); + + let mut estimated_costs_only_with_layer_info = Some(std::collections::HashMap::new()); + let ops = drive + .token_mint_many_operations_v0( + Identifier::new(token_id), + vec![(Identifier::new(id_a), 1)], + 999, + true, + &mut estimated_costs_only_with_layer_info, + None, + platform_version, + ) + .expect("expected operations to be produced"); + + assert!( + !ops.is_empty(), + "expected mint_many_operations_v0 to produce at least one op" + ); + + // Supply must not have advanced by 999 — operations were produced + // but never applied. + let supply_after = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch supply after") + .unwrap_or(0); + assert_eq!( + supply_before, supply_after, + "operations_v0 should not mutate on-disk supply" + ); + + // Balance likewise should be absent or unchanged. + let balance = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("fetch balance") + .unwrap_or(0); + assert_eq!(balance, 0, "balance must not have been applied"); + } + + #[test] + fn should_mint_many_with_many_recipients_and_conserve_total() { + // A stress-style test: ensure that across several recipients with + // varied weights the sum of distributed balances exactly equals + // the issuance amount (the last recipient takes the rounding + // remainder). + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let ids: Vec<[u8; 32]> = (0..7).map(|i| insert_identity(&drive, 100 + i)).collect(); + let weights: Vec = vec![1, 2, 3, 4, 5, 6, 7]; + let total = 12_345u64; + + let recipients: Vec<(Identifier, u64)> = ids + .iter() + .zip(weights.iter()) + .map(|(id, w)| (Identifier::new(*id), *w)) + .collect(); + + drive + .token_mint_many_v0( + Identifier::new(token_id), + recipients, + total, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected mint_many to succeed"); + + let mut sum = 0u64; + for id in &ids { + let balance = drive + .fetch_identity_token_balance(token_id, *id, None, platform_version) + .expect("fetch balance") + .unwrap_or(0); + sum += balance; + } + assert_eq!( + sum, total, + "sum of all recipient balances must equal issuance amount" + ); + + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("fetch supply") + .expect("supply present"); + assert_eq!(supply, total); + } + + #[test] + fn should_mint_many_with_equal_weights_distributes_evenly_with_last_absorbing_remainder() { + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 200); + let id_b = insert_identity(&drive, 201); + let id_c = insert_identity(&drive, 202); + + // 100 / 3 does not divide evenly. Per div_ceil, the first two get + // 34, and the remainder falls to the last recipient. + let total = 100u64; + drive + .token_mint_many_v0( + Identifier::new(token_id), + vec![ + (Identifier::new(id_a), 1), + (Identifier::new(id_b), 1), + (Identifier::new(id_c), 1), + ], + total, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("mint_many ok"); + + let balance_a = drive + .fetch_identity_token_balance(token_id, id_a, None, platform_version) + .expect("a") + .expect("a exists"); + let balance_b = drive + .fetch_identity_token_balance(token_id, id_b, None, platform_version) + .expect("b") + .expect("b exists"); + let balance_c = drive + .fetch_identity_token_balance(token_id, id_c, None, platform_version) + .expect("c") + .expect("c exists"); + + assert_eq!(balance_a, 34); + assert_eq!(balance_b, 34); + assert_eq!(balance_c, 32); + assert_eq!(balance_a + balance_b + balance_c, total); + } + + #[test] + fn should_mint_many_fee_result_is_non_zero_on_apply() { + // Exercises the token_mint_many_v0 path that computes a FeeResult. + let (drive, token_id) = setup_drive_with_token(); + let platform_version = PlatformVersion::latest(); + + let id_a = insert_identity(&drive, 300); + + let fee_result = drive + .token_mint_many_v0( + Identifier::new(token_id), + vec![(Identifier::new(id_a), 1)], + 500, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("mint_many ok"); + + // Applying writes should cost something in processing and/or storage. + assert!( + fee_result.processing_fee > 0 || fee_result.storage_fee > 0, + "expected non-zero fee for applied mint_many, got {:?}", + fee_result + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/system/add_to_token_total_supply/v0/mod.rs b/packages/rs-drive/src/drive/tokens/system/add_to_token_total_supply/v0/mod.rs index 842e6fdd89d..61c0cdf85e1 100644 --- a/packages/rs-drive/src/drive/tokens/system/add_to_token_total_supply/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/system/add_to_token_total_supply/v0/mod.rs @@ -163,3 +163,239 @@ impl Drive { Ok((drive_operations, added_amount)) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + + #[test] + fn should_add_to_existing_token_total_supply() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [1u8; 32]; + let contract_id = Identifier::from([3u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + // create_token_trees initializes supply to 0 — a subsequent call requires + // an existing supply entry so the replace_op path is exercised. + let (_fees, added) = drive + .add_to_token_total_supply_v0( + token_id, + 500, + false, + false, + true, + &block_info, + None, + platform_version, + ) + .expect("expected to add to total supply"); + assert_eq!(added, 500); + + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("expected to fetch supply"); + assert_eq!(supply, Some(500)); + + // Add more to exercise the replace path against a non-zero prior value + let (_fees, added2) = drive + .add_to_token_total_supply_v0( + token_id, + 250, + false, + false, + true, + &block_info, + None, + platform_version, + ) + .expect("expected to add to total supply again"); + assert_eq!(added2, 250); + + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("expected to fetch supply"); + assert_eq!(supply, Some(750)); + } + + #[test] + fn should_error_when_adding_to_non_existent_token_without_allow_first_mint() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [7u8; 32]; + + // No token tree created — supply does not exist, allow_first_mint=false -> error + let result = drive.add_to_token_total_supply_v0( + token_id, + 100, + false, // allow_first_mint + false, + true, + &block_info, + None, + platform_version, + ); + + assert!( + result.is_err(), + "expected CriticalCorruptedState error when adding to non-existent supply" + ); + } + + #[test] + fn should_error_on_overflow_when_allow_saturation_is_false() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [2u8; 32]; + let contract_id = Identifier::from([4u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + // Seed with a large but valid value + drive + .add_to_token_total_supply_v0( + token_id, + (i64::MAX as u64) - 10, + false, + false, + true, + &block_info, + None, + platform_version, + ) + .expect("expected to add a large seed supply"); + + // Now try to add enough to overflow i64 — without saturation this must error + let result = drive.add_to_token_total_supply_v0( + token_id, + 100, + false, + false, // allow_saturation + true, + &block_info, + None, + platform_version, + ); + + assert!( + result.is_err(), + "expected overflow error when allow_saturation is false" + ); + } + + #[test] + fn should_saturate_on_overflow_when_allow_saturation_is_true() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [3u8; 32]; + let contract_id = Identifier::from([5u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + // Seed near i64::MAX + let seed = (i64::MAX as u64) - 10; + drive + .add_to_token_total_supply_v0( + token_id, + seed, + false, + false, + true, + &block_info, + None, + platform_version, + ) + .expect("expected to add a large seed supply"); + + // Add more than headroom — saturation path must clamp to i64::MAX + let (_fees, added) = drive + .add_to_token_total_supply_v0( + token_id, + 100, + false, + true, // allow_saturation + true, + &block_info, + None, + platform_version, + ) + .expect("expected saturation to succeed"); + + // Only the headroom (10) should have been added + assert_eq!(added, 10); + + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("expected to fetch supply"); + assert_eq!(supply, Some(i64::MAX as u64)); + } + + #[test] + fn should_error_when_first_mint_amount_exceeds_i64_max() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [9u8; 32]; + + // allow_first_mint=true but amount > i64::MAX -> CriticalCorruptedState + let result = drive.add_to_token_total_supply_v0( + token_id, + (i64::MAX as u64) + 1, + true, // allow_first_mint + false, + true, + &block_info, + None, + platform_version, + ); + + assert!( + result.is_err(), + "expected error for first-mint amount over i64::MAX" + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/system/create_token_trees/v0/mod.rs b/packages/rs-drive/src/drive/tokens/system/create_token_trees/v0/mod.rs index f420688cd0f..173a8d7b7da 100644 --- a/packages/rs-drive/src/drive/tokens/system/create_token_trees/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/system/create_token_trees/v0/mod.rs @@ -249,3 +249,189 @@ impl Drive { Ok(batch_operations) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + + #[test] + fn should_create_token_trees_and_initialize_supply_to_zero() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [51u8; 32]; + let contract_id = Identifier::from([52u8; 32]); + + drive + .create_token_trees_v0( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + // Supply is initialized as SumItem(0) + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("expected to fetch supply"); + assert_eq!(supply, Some(0)); + + // Aggregated balances tree exists and sums to 0 + let balances = drive + .fetch_token_total_aggregated_identity_balances(token_id, None, platform_version) + .expect("expected to fetch balances"); + assert_eq!(balances, Some(0)); + } + + #[test] + fn should_error_on_double_creation_without_allow_already_exists() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [53u8; 32]; + let contract_id = Identifier::from([54u8; 32]); + + drive + .create_token_trees_v0( + contract_id, + 0, + token_id, + false, + false, // allow_already_exists + &block_info, + true, + None, + platform_version, + ) + .expect("first creation should succeed"); + + let result = drive.create_token_trees_v0( + contract_id, + 0, + token_id, + false, + false, // allow_already_exists + &block_info, + true, + None, + platform_version, + ); + + assert!( + result.is_err(), + "expected CorruptedDriveState on duplicate creation" + ); + } + + #[test] + fn should_succeed_on_double_creation_with_allow_already_exists() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [55u8; 32]; + let contract_id = Identifier::from([56u8; 32]); + + drive + .create_token_trees_v0( + contract_id, + 0, + token_id, + false, + true, // allow_already_exists (idempotent) + &block_info, + true, + None, + platform_version, + ) + .expect("first creation should succeed"); + + // Seed some supply so we can confirm it is not reset to 0 by a second call + drive + .add_to_token_total_supply( + token_id, + 999, + false, + false, + true, + &block_info, + None, + platform_version, + ) + .expect("expected to seed supply"); + + drive + .create_token_trees_v0( + contract_id, + 0, + token_id, + false, + true, // allow_already_exists -> idempotent no-op + &block_info, + true, + None, + platform_version, + ) + .expect("second idempotent creation should succeed"); + + // The supply should remain what we had (not re-initialized to 0) + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("expected to fetch supply"); + assert_eq!(supply, Some(999)); + } + + #[test] + fn should_respect_start_as_paused_flag() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id_active = [60u8; 32]; + let token_id_paused = [61u8; 32]; + let contract_id = Identifier::from([62u8; 32]); + + drive + .create_token_trees_v0( + contract_id, + 0, + token_id_active, + false, // start_as_paused + false, + &block_info, + true, + None, + platform_version, + ) + .expect("active creation should succeed"); + + drive + .create_token_trees_v0( + contract_id, + 1, + token_id_paused, + true, // start_as_paused + false, + &block_info, + true, + None, + platform_version, + ) + .expect("paused creation should succeed"); + + // Both tokens should have supply initialized to 0 + for tid in [token_id_active, token_id_paused] { + let supply = drive + .fetch_token_total_supply(tid, None, platform_version) + .expect("expected to fetch supply"); + assert_eq!(supply, Some(0)); + } + } +} diff --git a/packages/rs-drive/src/drive/tokens/system/fetch_token_total_aggregated_identity_balances/v0/mod.rs b/packages/rs-drive/src/drive/tokens/system/fetch_token_total_aggregated_identity_balances/v0/mod.rs index 035091d0527..b80169d0415 100644 --- a/packages/rs-drive/src/drive/tokens/system/fetch_token_total_aggregated_identity_balances/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/system/fetch_token_total_aggregated_identity_balances/v0/mod.rs @@ -73,3 +73,145 @@ impl Drive { Ok(total_token_aggregated_identity_balances_in_platform) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + + #[test] + fn should_return_none_for_non_existent_token() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let token_id = [21u8; 32]; + + let balances = drive + .fetch_token_total_aggregated_identity_balances_v0(token_id, None, platform_version) + .expect("expected fetch to succeed"); + + assert_eq!(balances, None); + } + + #[test] + fn should_return_zero_for_freshly_created_token_with_no_holders() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [22u8; 32]; + let contract_id = Identifier::from([23u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + let balances = drive + .fetch_token_total_aggregated_identity_balances_v0(token_id, None, platform_version) + .expect("expected fetch to succeed"); + + // The balances tree exists but is empty — sum is 0 + assert_eq!(balances, Some(0)); + } + + #[test] + fn should_aggregate_single_holder_balance() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [24u8; 32]; + let contract_id = Identifier::from([25u8; 32]); + let identity_id = [26u8; 32]; + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + drive + .add_to_identity_token_balance( + token_id, + identity_id, + 1_234, + &block_info, + true, + None, + platform_version, + None, + ) + .expect("expected to add token balance"); + + let balances = drive + .fetch_token_total_aggregated_identity_balances_v0(token_id, None, platform_version) + .expect("expected fetch to succeed"); + + assert_eq!(balances, Some(1_234)); + } + + #[test] + fn should_aggregate_multiple_holder_balances() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [30u8; 32]; + let contract_id = Identifier::from([31u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + for (identity_id, amount) in [ + ([32u8; 32], 100u64), + ([33u8; 32], 250u64), + ([34u8; 32], 650u64), + ] { + drive + .add_to_identity_token_balance( + token_id, + identity_id, + amount, + &block_info, + true, + None, + platform_version, + None, + ) + .expect("expected to add token balance"); + } + + let balances = drive + .fetch_token_total_aggregated_identity_balances_v0(token_id, None, platform_version) + .expect("expected fetch to succeed"); + + assert_eq!(balances, Some(1_000)); + } +} diff --git a/packages/rs-drive/src/drive/tokens/system/fetch_token_total_supply/v0/mod.rs b/packages/rs-drive/src/drive/tokens/system/fetch_token_total_supply/v0/mod.rs index abd233e22b8..1a391a1091c 100644 --- a/packages/rs-drive/src/drive/tokens/system/fetch_token_total_supply/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/system/fetch_token_total_supply/v0/mod.rs @@ -99,3 +99,139 @@ impl Drive { Ok(total_token_supply_in_platform) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + + #[test] + fn should_return_none_for_non_existent_token() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let token_id = [99u8; 32]; + + let supply = drive + .fetch_token_total_supply_v0(token_id, None, platform_version) + .expect("expected fetch to succeed"); + assert_eq!(supply, None); + } + + #[test] + fn should_return_zero_for_freshly_created_token() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [11u8; 32]; + let contract_id = Identifier::from([12u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + let supply = drive + .fetch_token_total_supply_v0(token_id, None, platform_version) + .expect("expected fetch to succeed"); + assert_eq!(supply, Some(0)); + } + + #[test] + fn should_return_supply_after_additions() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [13u8; 32]; + let contract_id = Identifier::from([14u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + drive + .add_to_token_total_supply( + token_id, + 7_500, + false, + false, + true, + &block_info, + None, + platform_version, + ) + .expect("expected to add supply"); + + let supply = drive + .fetch_token_total_supply_v0(token_id, None, platform_version) + .expect("expected fetch to succeed"); + assert_eq!(supply, Some(7_500)); + } + + #[test] + fn should_return_supply_with_cost() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [15u8; 32]; + let contract_id = Identifier::from([16u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + drive + .add_to_token_total_supply( + token_id, + 42, + false, + false, + true, + &block_info, + None, + platform_version, + ) + .expect("expected to add supply"); + + let (supply, fees) = drive + .fetch_token_total_supply_with_cost_v0(token_id, &block_info, None, platform_version) + .expect("expected fetch with cost to succeed"); + + assert_eq!(supply, Some(42)); + // At minimum, fetching costs something (read ops or storage) + assert!( + fees.processing_fee > 0 || fees.storage_fee > 0, + "expected non-zero fees for a fetch" + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/system/remove_from_token_total_supply/v0/mod.rs b/packages/rs-drive/src/drive/tokens/system/remove_from_token_total_supply/v0/mod.rs index 23591b17931..75fce56c16f 100644 --- a/packages/rs-drive/src/drive/tokens/system/remove_from_token_total_supply/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/system/remove_from_token_total_supply/v0/mod.rs @@ -144,3 +144,137 @@ impl Drive { Ok(drive_operations) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + + fn setup_token_with_supply(initial_supply: u64) -> (crate::drive::Drive, [u8; 32], BlockInfo) { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [1u8; 32]; + let contract_id = Identifier::from([3u8; 32]); + + drive + .create_token_trees( + contract_id, + 0, + token_id, + false, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to create token trees"); + + if initial_supply > 0 { + drive + .add_to_token_total_supply( + token_id, + initial_supply, + false, + false, + true, + &block_info, + None, + platform_version, + ) + .expect("expected to seed supply"); + } + + (drive, token_id, block_info) + } + + #[test] + fn should_remove_from_existing_total_supply() { + let platform_version = PlatformVersion::latest(); + let (drive, token_id, block_info) = setup_token_with_supply(1000); + + drive + .remove_from_token_total_supply_v0( + token_id, + 300, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to remove from total supply"); + + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("expected to fetch supply"); + assert_eq!(supply, Some(700)); + } + + #[test] + fn should_remove_to_exact_zero() { + let platform_version = PlatformVersion::latest(); + let (drive, token_id, block_info) = setup_token_with_supply(500); + + drive + .remove_from_token_total_supply_v0( + token_id, + 500, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to remove to zero"); + + let supply = drive + .fetch_token_total_supply(token_id, None, platform_version) + .expect("expected to fetch supply"); + assert_eq!(supply, Some(0)); + } + + #[test] + fn should_error_on_underflow() { + let platform_version = PlatformVersion::latest(); + let (drive, token_id, block_info) = setup_token_with_supply(100); + + let result = drive.remove_from_token_total_supply_v0( + token_id, + 200, + &block_info, + true, + None, + platform_version, + ); + + assert!( + result.is_err(), + "expected CorruptedDriveState underflow error" + ); + } + + #[test] + fn should_error_when_removing_from_non_existent_token() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let token_id = [42u8; 32]; + + // Never created the token tree — supply entry missing + let result = drive.remove_from_token_total_supply_v0( + token_id, + 10, + &block_info, + true, + None, + platform_version, + ); + + assert!( + result.is_err(), + "expected CorruptedDriveState when token supply missing" + ); + } +} From e7ecf09ec382e63a7729adc16651b76fcc8dbe91 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 21 Apr 2026 19:31:57 +0800 Subject: [PATCH 2/2] test(drive): tighten assertions per CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetch_identities_token_infos: assert the full expected map so missing identity IDs are caught, not just that returned values are None. - fetch_identity_token_infos: same — assert missing-token entries via full-map equality. - queries.rs: assert left_to_right direction in the two RangeFull tests so regressions that ignore the ascending flag get caught. - create_token_trees should_respect_start_as_paused_flag: actually fetch and assert TokenStatus.paused for both tokens — the old assertion was identical to should_create_token_trees_and_initialize_supply_to_zero and would pass even if start_as_paused were ignored. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fetch_identities_token_infos/v0/mod.rs | 13 ++++++------ .../info/fetch_identity_token_infos/v0/mod.rs | 13 ++++++------ .../rs-drive/src/drive/tokens/info/queries.rs | 8 ++++++++ .../system/create_token_trees/v0/mod.rs | 20 +++++++++++++++++++ 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/rs-drive/src/drive/tokens/info/fetch_identities_token_infos/v0/mod.rs b/packages/rs-drive/src/drive/tokens/info/fetch_identities_token_infos/v0/mod.rs index 0ddf711bcc0..4f689d29f8b 100644 --- a/packages/rs-drive/src/drive/tokens/info/fetch_identities_token_infos/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/info/fetch_identities_token_infos/v0/mod.rs @@ -226,13 +226,12 @@ mod tests { .fetch_identities_token_infos_v0(token_id, &identity_ids, None, platform_version) .expect("expected fetch to succeed even when token tree does not exist"); - for (_, v) in infos.iter() { - assert!( - v.is_none(), - "expected all values to be None for non-existent token, got {:?}", - infos - ); - } + let expected: BTreeMap<[u8; 32], Option> = + identity_ids.into_iter().map(|id| (id, None)).collect(); + assert_eq!( + infos, expected, + "expected a None entry for every requested identity" + ); } #[test] diff --git a/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_infos/v0/mod.rs b/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_infos/v0/mod.rs index 4d1f9d6cc6d..5e1d0509407 100644 --- a/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_infos/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/info/fetch_identity_token_infos/v0/mod.rs @@ -230,13 +230,12 @@ mod tests { // grove_get_raw_path_query_with_optional returns entries for each requested key // even when nothing exists — they should all be None. - for (_, v) in infos.iter() { - assert!( - v.is_none(), - "expected every entry to be None, got {:?}", - infos - ); - } + let expected: BTreeMap<[u8; 32], Option> = + token_ids.into_iter().map(|id| (id, None)).collect(); + assert_eq!( + infos, expected, + "expected a None entry for every requested token" + ); } #[test] diff --git a/packages/rs-drive/src/drive/tokens/info/queries.rs b/packages/rs-drive/src/drive/tokens/info/queries.rs index c30d922f41c..fabc225d0de 100644 --- a/packages/rs-drive/src/drive/tokens/info/queries.rs +++ b/packages/rs-drive/src/drive/tokens/info/queries.rs @@ -204,6 +204,10 @@ mod tests { assert_eq!(path_query.path, token_identity_infos_path_vec(token_id)); assert_eq!(path_query.query.limit, Some(50)); + assert!( + path_query.query.query.left_to_right, + "expected ascending query direction when ascending=true" + ); let items = &path_query.query.query.items; assert_eq!(items.len(), 1); @@ -292,6 +296,10 @@ mod tests { let path_query = Drive::token_infos_for_range_query(token_id, None, false, 100); assert_eq!(path_query.query.limit, Some(100)); + assert!( + !path_query.query.query.left_to_right, + "expected descending query direction when ascending=false" + ); let items = &path_query.query.query.items; assert_eq!(items.len(), 1); assert!( diff --git a/packages/rs-drive/src/drive/tokens/system/create_token_trees/v0/mod.rs b/packages/rs-drive/src/drive/tokens/system/create_token_trees/v0/mod.rs index 173a8d7b7da..3b19f235595 100644 --- a/packages/rs-drive/src/drive/tokens/system/create_token_trees/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/system/create_token_trees/v0/mod.rs @@ -255,6 +255,7 @@ mod tests { use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::block_info::BlockInfo; use dpp::prelude::Identifier; + use dpp::tokens::status::v0::TokenStatusV0Accessors; use dpp::version::PlatformVersion; #[test] @@ -433,5 +434,24 @@ mod tests { .expect("expected to fetch supply"); assert_eq!(supply, Some(0)); } + + // And critically: the paused flag must actually be persisted distinctly. + let active_status = drive + .fetch_token_status(token_id_active, None, platform_version) + .expect("expected to fetch active token status") + .expect("active token status must exist"); + assert!( + !active_status.paused(), + "token created with start_as_paused=false should not be paused" + ); + + let paused_status = drive + .fetch_token_status(token_id_paused, None, platform_version) + .expect("expected to fetch paused token status") + .expect("paused token status must exist"); + assert!( + paused_status.paused(), + "token created with start_as_paused=true should be paused" + ); } }