From c47044bb29a9df342ed8d23c7f6ad2917554ea85 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 7 Oct 2026 23:27:19 +0700 Subject: [PATCH 1/3] fix: omit unrelated co-signer balances from group burn proofs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return verified signer power/status with no token balance for non-proposer group burns in the shared client verifier used by v0 and v1. Preserve proposer balances (including zero), history results, and the signer root used by owner-credit and quorum/app-hash verification. Document Option/undefined semantics and test SDK, WASM, and wallet FFI forwarding, including closed co-signers returning empty balances JSON. No prover, proof format, protocol table, or consensus execution changes. Test would have caught this in CI: ✖ before fix, ✔ after. Funded and absent closers failed in both generations on 1742775f6163: Some(50) instead of None, and IncorrectProof after a successful burn. The same regressions pass after the fix; 11 focused Drive and 9 status tests pass, along with 6 native consumer tests. Remaining compile/CI verification is tracked in the draft PR. --- .../rs-drive-proof-verifier/src/verify.rs | 60 ++ .../v0/group_burn_tests.rs | 637 ++++++++++++++++++ .../v0/mod.rs | 56 +- .../rs-platform-wallet-ffi/src/tokens/burn.rs | 84 ++- .../src/platform/tokens/transitions/burn.rs | 49 +- .../wasm-sdk/src/state_transitions/token.rs | 108 ++- 6 files changed, 950 insertions(+), 44 deletions(-) create mode 100644 packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/group_burn_tests.rs diff --git a/packages/rs-drive-proof-verifier/src/verify.rs b/packages/rs-drive-proof-verifier/src/verify.rs index 13e5188b5e8..1025685c736 100644 --- a/packages/rs-drive-proof-verifier/src/verify.rs +++ b/packages/rs-drive-proof-verifier/src/verify.rs @@ -644,4 +644,64 @@ mod tests { "error should mention empty signature, got: {err_msg}" ); } + + #[test] + fn should_reject_a_verified_result_root_that_differs_from_the_signed_app_hash() { + let metadata = test_metadata(); + let provider = ValidKeyContextProvider::new(); + let (key, _) = test_keypair(); + let root = [7; 32]; + let mut proof = Proof { + grovedb_proof: grovedb_proof_bytes(1), + quorum_hash: vec![2; 32], + signature: vec![], + round: 1, + block_id_hash: vec![3; 32], + quorum_type: 1, + }; + let state_id = StateId { + app_version: metadata.protocol_version as u64, + core_chain_locked_height: metadata.core_chain_locked_height, + time: metadata.time_ms, + app_hash: root.to_vec(), + height: metadata.height, + }; + let vote = CanonicalVote { + r#type: SignedMsgType::Precommit.into(), + block_id: proof.block_id_hash.clone(), + chain_id: metadata.chain_id.clone(), + height: metadata.height as i64, + round: proof.round as i64, + state_id: state_id + .calculate_msg_hash( + &metadata.chain_id, + metadata.height as i64, + proof.round as i32, + ) + .expect("hash signed state"), + }; + let digest = vote + .calculate_sign_hash( + &metadata.chain_id, + proof.quorum_type as u8, + &[2; 32], + metadata.height as i64, + proof.round as i32, + ) + .expect("hash vote"); + proof.signature = key + .sign(bls_signatures::SignatureSchemes::Basic, &digest) + .expect("sign vote") + .as_raw_value() + .to_compressed() + .to_vec(); + verify_tenderdash_proof(&proof, &metadata, &root, &provider) + .expect("matching app hash control"); + let mut different_root = root; + different_root[0] ^= 1; + assert!(matches!( + verify_tenderdash_proof(&proof, &metadata, &different_root, &provider), + Err(Error::InvalidSignature { .. }) + )); + } } diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/group_burn_tests.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/group_burn_tests.rs new file mode 100644 index 00000000000..82229023160 --- /dev/null +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/group_burn_tests.rs @@ -0,0 +1,637 @@ +use super::*; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +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::associated_token::token_keeps_history_rules::v0::TokenKeepsHistoryRulesV0; +use dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; +use dpp::data_contract::config::v0::DataContractConfigV0; +use dpp::data_contract::group::v0::GroupV0; +use dpp::data_contract::group::Group; +use dpp::data_contract::v1::DataContractV1; +use dpp::data_contract::DataContract; +use dpp::group::action_event::GroupActionEvent; +use dpp::group::group_action::v0::GroupActionV0; +use dpp::group::group_action::GroupAction; +use dpp::group::GroupStateTransitionInfo; +use dpp::identity::accessors::IdentitySettersV0; +use dpp::identity::Identity; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; +use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; +use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use dpp::state_transition::batch_transition::token_burn_transition::v0::TokenBurnTransitionV0; +use dpp::state_transition::batch_transition::token_burn_transition::TokenBurnTransition; +use dpp::state_transition::batch_transition::{BatchTransition, BatchTransitionV1}; +use dpp::tokens::token_event::TokenEvent; +use std::collections::BTreeMap; +use std::sync::Arc; + +const CONTRACT: [u8; 32] = [3; 32]; +const PROPOSER: [u8; 32] = [1; 32]; +const COSIGNER: [u8; 32] = [2; 32]; +const CONTRACT_OWNER: [u8; 32] = [4; 32]; +const CREDITS: u64 = 1_000_000; + +struct Fixture { + drive: Drive, + contract: DataContract, + transition: StateTransition, + token_id: Identifier, + action_id: Identifier, + signer: Identifier, +} + +impl Fixture { + fn new( + signer_is_proposer: bool, + closed: bool, + cosigner_balance: Option, + burn_amount: u64, + keeps_history: bool, + platform_version: &PlatformVersion, + ) -> Self { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let mut token = TokenConfigurationV0::default_most_restrictive().with_base_supply(0); + token.keeps_history = + TokenKeepsHistoryRulesV0::default_for_keeping_all_history(keeps_history).into(); + token + .manual_burning_rules + .set_authorized_to_make_change_action_takers(AuthorizedActionTakers::Group(0)); + let contract = DataContract::V1(DataContractV1 { + id: CONTRACT.into(), + version: 0, + owner_id: CONTRACT_OWNER.into(), + document_types: Default::default(), + config: DataContractConfigV0::default().into(), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: BTreeMap::from([( + 0, + Group::V0(GroupV0 { + members: [(PROPOSER.into(), 3), (COSIGNER.into(), 5)].into(), + required_power: if signer_is_proposer && closed { + 3 + } else if closed { + 8 + } else { + 9 + }, + }), + )]), + tokens: BTreeMap::from([(0, TokenConfiguration::V0(token))]), + keywords: Vec::new(), + description: None, + }); + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("insert grouped token contract"); + let token_id = contract.token_id(0).expect("token at position zero"); + if keeps_history { + let history_contract = + load_system_data_contract(SystemDataContract::TokenHistory, platform_version) + .expect("load token history contract"); + drive + .insert_contract( + &history_contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("insert token history contract"); + } + for id in [PROPOSER, COSIGNER] { + let mut identity = + Identity::random_identity(1, Some(14), platform_version).expect("create identity"); + identity.set_id(id.into()); + identity.set_balance(CREDITS); + drive + .add_new_identity( + identity, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("insert signer credit balance"); + } + for (id, amount) in [(PROPOSER, Some(10)), (COSIGNER, cosigner_balance)] { + if let Some(amount) = amount { + drive + .token_mint( + token_id.to_buffer(), + id, + amount, + true, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("fund token balance"); + } + } + let action_id = TokenBurnTransition::calculate_action_id_with_fields( + &token_id.to_buffer(), + &PROPOSER, + 1, + burn_amount, + ); + let action = GroupAction::V0(GroupActionV0 { + contract_id: CONTRACT.into(), + proposer_id: PROPOSER.into(), + token_contract_position: 0, + event: GroupActionEvent::TokenEvent(TokenEvent::Burn( + burn_amount, + PROPOSER.into(), + None, + )), + }); + drive + .add_group_action( + CONTRACT.into(), + 0, + Some(action), + signer_is_proposer && closed, + action_id, + PROPOSER.into(), + 3, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("record proposal"); + if !signer_is_proposer { + drive + .add_group_action( + CONTRACT.into(), + 0, + None, + closed, + action_id, + COSIGNER.into(), + 5, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("record co-signature"); + } + if closed { + drive + .token_burn( + token_id.to_buffer(), + PROPOSER, + burn_amount, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("burn proposer tokens"); + } + let signer = if signer_is_proposer { + PROPOSER + } else { + COSIGNER + } + .into(); + if closed && keeps_history { + let operations = drive + .add_token_transaction_history_operations( + token_id, + signer, + 1, + TokenEvent::Burn(burn_amount, PROPOSER.into(), None), + &BlockInfo::default(), + &mut None, + None, + platform_version, + ) + .expect("build burn history operations"); + drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("record burn history"); + } + let transition = StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: signer, + transitions: vec![BatchedTransition::Token(TokenTransition::Burn( + TokenBurnTransition::V0(TokenBurnTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 1, + token_contract_position: 0, + data_contract_id: CONTRACT.into(), + token_id, + using_group_info: Some(GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: signer_is_proposer, + }), + }), + burn_amount, + public_note: None, + }), + ))], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + })); + Self { + drive, + contract, + transition, + token_id, + action_id, + signer, + } + } + + fn proof(&self, platform_version: &PlatformVersion) -> Vec { + self.drive + .prove_state_transition(&self.transition, None, platform_version) + .expect("prove stored burn result") + .into_data() + .expect("proof bytes") + } + + fn verify( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, StateTransitionProofOutcome), Error> { + Drive::verify_state_transition_was_executed_with_proof( + &self.transition, + &BlockInfo::default(), + proof, + &|_| Ok(Some(Arc::new(self.contract.clone()))), + platform_version, + ) + } +} + +fn assert_group_burn( + signer_is_proposer: bool, + closed: bool, + cosigner_balance: Option, + burn_amount: u64, + platform_version: &PlatformVersion, +) { + let fixture = Fixture::new( + signer_is_proposer, + closed, + cosigner_balance, + burn_amount, + false, + platform_version, + ); + let proof = fixture.proof(platform_version); + let (signer_root, status, power) = Drive::verify_action_signer_and_total_power( + &proof, + CONTRACT.into(), + 0, + None, + fixture.action_id, + fixture.signer, + true, + platform_version, + ) + .expect("verify exact group signer and status"); + let (root, outcome) = fixture + .verify(&proof, platform_version) + .expect("verify successful group burn"); + assert_eq!( + root, signer_root, + "return the authenticated signer/status root" + ); + assert_eq!( + root, + fixture + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("stored root") + ); + assert!(outcome.is_execution_proved()); + assert_eq!( + outcome.owner_balance(), + if platform_version.protocol_version >= 14 { + Some(CREDITS) + } else { + None + } + ); + assert_eq!( + status, + if closed { + GroupActionStatus::ActionClosed + } else { + GroupActionStatus::ActionActive + } + ); + assert_eq!(power, if signer_is_proposer { 3 } else { 8 }); + match outcome.into_result() { + VerifiedTokenGroupActionWithTokenBalance(actual_power, actual_status, balance) => { + assert_eq!((actual_power, actual_status), (power, status)); + assert_eq!( + balance, + signer_is_proposer.then_some(if closed { 10 - burn_amount } else { 10 }) + ); + } + other => panic!("expected group balance result, got {other:?}"), + } + let supply_proof = fixture + .drive + .prove_token_total_supply_and_aggregated_identity_balances( + fixture.token_id.to_buffer(), + None, + platform_version, + ) + .expect("prove token supply"); + let (supply_root, supply) = Drive::verify_token_total_supply_and_aggregated_identity_balance( + &supply_proof, + fixture.token_id.to_buffer(), + false, + platform_version, + ) + .expect("verify token supply"); + assert_eq!(supply_root, root); + assert_eq!( + supply.token_supply, + (10 + cosigner_balance.unwrap_or_default() - if closed { burn_amount } else { 0 }) as i64 + ); + // Independent snapshots confirm the burn debited the proposer, leaving the co-signer alone. + for (id, expected) in [ + (PROPOSER, Some(if closed { 10 - burn_amount } else { 10 })), + (COSIGNER, cosigner_balance), + ] { + let query = Drive::token_balance_for_identity_id_query(fixture.token_id.to_buffer(), id); + let proof = fixture + .drive + .grove_get_proved_path_query(&query, None, &mut vec![], &platform_version.drive) + .expect("prove ledger balance"); + assert_eq!( + Drive::verify_token_balance_for_identity_id( + &proof, + fixture.token_id.to_buffer(), + id, + false, + platform_version + ) + .expect("verify ledger balance"), + (root, expected) + ); + } +} + +#[test] +fn should_omit_unrelated_balance_when_cosigner_closes_group_burn_v0() { + assert_group_burn( + false, + true, + Some(50), + 3, + PlatformVersion::get(13).expect("PV13"), + ); +} + +#[test] +fn should_omit_unrelated_balance_when_cosigner_closes_group_burn_v1() { + assert_group_burn(false, true, Some(50), 3, PlatformVersion::latest()); +} + +#[test] +fn should_verify_group_burn_when_closing_signer_has_no_token_balance_v0() { + assert_group_burn( + false, + true, + None, + 3, + PlatformVersion::get(13).expect("PV13"), + ); +} + +#[test] +fn should_verify_group_burn_when_closing_signer_has_no_token_balance_v1() { + assert_group_burn(false, true, None, 3, PlatformVersion::latest()); +} + +#[test] +fn should_preserve_proposer_group_burn_balance_including_zero() { + for version in [ + PlatformVersion::get(13).expect("PV13"), + PlatformVersion::latest(), + ] { + for (closed, amount) in [(false, 3), (true, 3), (true, 10)] { + assert_group_burn(true, closed, Some(50), amount, version); + } + } +} + +#[test] +fn should_omit_cosigner_balance_while_group_burn_is_active() { + for version in [ + PlatformVersion::get(13).expect("PV13"), + PlatformVersion::latest(), + ] { + assert_group_burn(false, false, Some(50), 3, version); + } +} + +#[test] +fn should_reject_group_burn_owner_credit_balance_from_another_root() { + let version = PlatformVersion::latest(); + let fixture = Fixture::new(false, true, Some(50), 3, false, version); + let proof = fixture.proof(version); + let (mut root, _, _) = Drive::verify_action_signer_and_total_power( + &proof, + CONTRACT.into(), + 0, + None, + fixture.action_id, + fixture.signer, + true, + version, + ) + .expect("verify signer root"); + Drive::owner_balance_of_verified_transition(&fixture.transition, None, &proof, root, version) + .expect("matching root control"); + root[0] ^= 1; + let error = Drive::owner_balance_of_verified_transition( + &fixture.transition, + None, + &proof, + root, + version, + ) + .expect_err("owner credit balance must share the signer/status root"); + assert!( + matches!(error, Error::Proof(ProofError::CorruptedProof(message)) + if message.contains("different states")) + ); +} + +#[test] +fn should_preserve_group_burn_history_for_proposers_and_cosigners() { + for version in [ + PlatformVersion::get(13).expect("PV13"), + PlatformVersion::latest(), + ] { + for proposer in [true, false] { + for closed in [true, false] { + let fixture = Fixture::new(proposer, closed, None, 3, true, version); + let proof = fixture.proof(version); + let (root, outcome) = fixture + .verify(&proof, version) + .expect("verify history-enabled group burn"); + assert_eq!( + root, + fixture + .drive + .grove + .root_hash(None, &version.drive.grove_version) + .unwrap() + .expect("stored root") + ); + match outcome.into_result() { + VerifiedTokenGroupActionWithDocument(power, document) => { + assert_eq!(power, if proposer { 3 } else { 8 }); + assert_eq!(document.is_some(), closed); + } + other => panic!("expected history result, got {other:?}"), + } + } + } + } +} + +#[test] +fn should_verify_group_burn_with_other_active_actions_in_the_group() { + for version in [ + PlatformVersion::get(13).expect("PV13"), + PlatformVersion::latest(), + ] { + let fixture = Fixture::new(false, true, Some(50), 3, false, version); + let action = GroupAction::V0(GroupActionV0 { + contract_id: CONTRACT.into(), + proposer_id: PROPOSER.into(), + token_contract_position: 0, + event: GroupActionEvent::TokenEvent(TokenEvent::Burn(1, PROPOSER.into(), None)), + }); + fixture + .drive + .add_group_action( + CONTRACT.into(), + 0, + Some(action), + false, + [18; 32].into(), + PROPOSER.into(), + 3, + &BlockInfo::default(), + true, + None, + version, + ) + .expect("record unrelated active action"); + let proof = fixture.proof(version); + let (_, outcome) = fixture + .verify(&proof, version) + .expect("verify exact closed action in mixed group"); + assert!(matches!( + outcome.into_result(), + VerifiedTokenGroupActionWithTokenBalance(8, GroupActionStatus::ActionClosed, None) + )); + } +} + +#[test] +fn should_preserve_standalone_burn_balances_and_history() { + for version in [ + PlatformVersion::get(13).expect("PV13"), + PlatformVersion::latest(), + ] { + for history in [false, true] { + let mut fixture = Fixture::new(true, true, None, 3, history, version); + let StateTransition::Batch(BatchTransition::V1(batch)) = &mut fixture.transition else { + panic!("token batch"); + }; + let BatchedTransition::Token(TokenTransition::Burn(TokenBurnTransition::V0(burn))) = + &mut batch.transitions[0] + else { + panic!("burn"); + }; + let TokenBaseTransition::V0(base) = &mut burn.base; + base.using_group_info = None; + let proof = fixture.proof(version); + let (_, outcome) = fixture + .verify(&proof, version) + .expect("verify standalone burn"); + match outcome.into_result() { + VerifiedTokenBalance(owner, 7) if !history => { + assert_eq!(owner, Identifier::from(PROPOSER)); + } + VerifiedTokenActionWithDocument(_) if history => (), + other => panic!("unexpected standalone burn result {other:?}"), + } + } + } +} + +#[test] +fn should_reject_group_burn_proofs_for_another_action_group_or_signer() { + for version in [ + PlatformVersion::get(13).expect("PV13"), + PlatformVersion::latest(), + ] { + let mut fixture = Fixture::new(false, true, Some(50), 3, false, version); + let proof = fixture.proof(version); + fixture + .verify(&proof, version) + .expect("exact action control"); + let original = fixture.transition.clone(); + for selector in 0..3 { + fixture.transition = original.clone(); + let StateTransition::Batch(BatchTransition::V1(batch)) = &mut fixture.transition else { + panic!("token batch"); + }; + let BatchedTransition::Token(TokenTransition::Burn(TokenBurnTransition::V0(burn))) = + &mut batch.transitions[0] + else { + panic!("burn"); + }; + let TokenBaseTransition::V0(base) = &mut burn.base; + let info = base.using_group_info.as_mut().expect("group info"); + match selector { + 0 => info.action_id = [19; 32].into(), + 1 => info.group_contract_position = 1, + _ => batch.owner_id = [9; 32].into(), + } + assert!( + fixture.verify(&proof, version).is_err(), + "reject mismatched action scope" + ); + } + } +} diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 37c7d109ef8..2a9e835cbfb 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -86,6 +86,9 @@ use crate::error::Error; use crate::error::proof::ProofError; use crate::query::{ContractLookupFn, SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; +#[cfg(test)] +mod group_burn_tests; + impl Drive { #[inline(always)] pub(super) fn verify_state_transition_was_executed_with_proof_v0( @@ -767,7 +770,7 @@ impl Drive { } else if let Some(group_state_transition_info) = token_transition.base().using_group_info() { - let (_root_hash, status, sum_power) = + let (signer_root_hash, status, sum_power) = Drive::verify_action_signer_and_total_power( proof, data_contract_id, @@ -779,27 +782,38 @@ impl Drive { platform_version, )?; - let (root_hash, balance) = - Drive::verify_token_balance_for_identity_id( - proof, - token_id.into_buffer(), - owner_id.into_buffer(), - true, - platform_version, - )?; - if status == GroupActionStatus::ActionClosed - && balance.is_none() - { - return Err(Error::Proof(ProofError::IncorrectProof( - format!("proof did not contain token balance for identity {} expected to exist because of state transition (token burn)", owner_id)))); - }; + // Client proof interpretation is not used by block validation or + // execution. A co-signer's balance is unrelated to the burn target. + if !group_state_transition_info.action_is_proposer { + Ok(( + signer_root_hash, + VerifiedTokenGroupActionWithTokenBalance( + sum_power, status, None, + ), + )) + } else { + let (root_hash, balance) = + Drive::verify_token_balance_for_identity_id( + proof, + token_id.into_buffer(), + owner_id.into_buffer(), + true, + platform_version, + )?; + if status == GroupActionStatus::ActionClosed + && balance.is_none() + { + return Err(Error::Proof(ProofError::IncorrectProof( + format!("proof did not contain token balance for identity {} expected to exist because of state transition (token burn)", owner_id)))); + }; - Ok(( - root_hash, - VerifiedTokenGroupActionWithTokenBalance( - sum_power, status, balance, - ), - )) + Ok(( + root_hash, + VerifiedTokenGroupActionWithTokenBalance( + sum_power, status, balance, + ), + )) + } } else { { let (root_hash, Some(balance)) = diff --git a/packages/rs-platform-wallet-ffi/src/tokens/burn.rs b/packages/rs-platform-wallet-ffi/src/tokens/burn.rs index e380a3de31d..386ad767a5e 100644 --- a/packages/rs-platform-wallet-ffi/src/tokens/burn.rs +++ b/packages/rs-platform-wallet-ffi/src/tokens/burn.rs @@ -4,6 +4,7 @@ use std::ffi::CStr; use std::os::raw::c_char; use dash_sdk::platform::tokens::transitions::BurnResult; +use dpp::prelude::Identifier; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use super::balances_json::{single_token_balance_to_json_cstring, write_empty_balances_json}; @@ -22,9 +23,10 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// string holding a JSON object mapping the burning identity's base58 /// id to its proof-verified remaining balance, encoded as a decimal /// **string** (u64 exceeds JSON's safe-integer range), e.g. -/// `{"": ""}`. History-tracking / group-action -/// tokens carry no per-identity balance in the proof result, so an -/// empty object `{}` is written. The caller owns the string and must +/// `{"": ""}`. A no-history group burn carries +/// a balance only when the caller proposed the action. +/// History results and co-signers carry no per-identity balance, so +/// an empty object `{}` is written. The caller owns the string and must /// free it via /// [`platform_wallet_string_free`](crate::types::platform_wallet_string_free). /// On error nothing is written through `out_balances_json` (it is set @@ -95,31 +97,71 @@ pub unsafe extern "C" fn platform_wallet_token_burn( let result = unwrap_option_or_return!(option); let burn_result = unwrap_result_or_return!(result); - // Map the proof-verified outcome to the balances JSON. The standard - // (non-history, non-group) burn carries the owner's remaining - // balance; a group-authorized burn carries the caller's remaining - // balance once the action CLOSES (Drive's proof verifier guarantees - // `Some` whenever the group action is closed — keyed by `id`, the - // burn caller the proof verifies the balance for). The document - // variants, and a group action still awaiting co-signatures - // (`balance == None`), have nothing to persist. + *out_balances_json = unwrap_result_or_return!(burn_result_to_balances_json(burn_result, &id)); + + PlatformWalletFFIResult::ok() +} + +// A no-history group burn may carry the proposer's balance, including zero. +// Co-signers carry no balance even after closure; history results also have +// nothing to persist. An empty object leaves existing cached balances alone. +fn burn_result_to_balances_json( + burn_result: BurnResult, + caller: &Identifier, +) -> Result<*mut c_char, PlatformWalletFFIResult> { match burn_result { BurnResult::TokenBalance(owner, remaining) => { - let c_str = - unwrap_result_or_return!(single_token_balance_to_json_cstring(&owner, remaining)); - *out_balances_json = c_str; + single_token_balance_to_json_cstring(&owner, remaining) } BurnResult::GroupActionWithBalance(_, _, Some(remaining)) => { - let c_str = - unwrap_result_or_return!(single_token_balance_to_json_cstring(&id, remaining)); - *out_balances_json = c_str; + single_token_balance_to_json_cstring(caller, remaining) } BurnResult::HistoricalDocument(_) | BurnResult::GroupActionWithDocument(_, _) - | BurnResult::GroupActionWithBalance(_, _, None) => { - *out_balances_json = unwrap_result_or_return!(write_empty_balances_json()); - } + | BurnResult::GroupActionWithBalance(_, _, None) => write_empty_balances_json(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::group::group_action_status::GroupActionStatus; + use dpp::platform_value::string_encoding::Encoding; + use std::ffi::CString; + + #[test] + fn should_return_empty_balances_for_a_closed_group_burn_cosigner() { + let caller = Identifier::from([2; 32]); + let ptr = burn_result_to_balances_json( + BurnResult::GroupActionWithBalance(8, GroupActionStatus::ActionClosed, None), + &caller, + ) + .expect("serialize closed co-signer result"); + let json = unsafe { CString::from_raw(ptr) }; + assert_eq!(json.to_str().expect("UTF-8 JSON"), "{}"); } - PlatformWalletFFIResult::ok() + #[test] + fn should_return_proposer_group_burn_balance_including_zero() { + let caller = Identifier::from([1; 32]); + for balance in [7, 0] { + let ptr = burn_result_to_balances_json( + BurnResult::GroupActionWithBalance( + 3, + GroupActionStatus::ActionClosed, + Some(balance), + ), + &caller, + ) + .expect("serialize proposer result"); + let json = unsafe { CString::from_raw(ptr) }; + let parsed: serde_json::Value = + serde_json::from_str(json.to_str().expect("UTF-8 JSON")) + .expect("parse balance object"); + assert_eq!( + parsed, + serde_json::json!({caller.to_string(Encoding::Base58): balance.to_string()}) + ); + } + } } diff --git a/packages/rs-sdk/src/platform/tokens/transitions/burn.rs b/packages/rs-sdk/src/platform/tokens/transitions/burn.rs index 49e7b83aac1..fbc5cc3b955 100644 --- a/packages/rs-sdk/src/platform/tokens/transitions/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/transitions/burn.rs @@ -26,7 +26,8 @@ pub enum BurnResult { HistoricalDocument(Document), /// Group-based burn action with optional document for history. GroupActionWithDocument(GroupSumPower, Option), - /// Group-based burn action with balance and status information. + /// Group-based burn action with status and an optional proposer balance. + /// Non-proposer co-signers receive no token balance, including for closed actions. GroupActionWithBalance(GroupSumPower, GroupActionStatus, Option), } @@ -78,6 +79,12 @@ impl Sdk { .broadcast_and_wait_for_affected_state::(self, put_settings) .await?; + BurnResult::from_proof_result(proof_result) + } +} + +impl BurnResult { + fn from_proof_result(proof_result: StateTransitionProofResult) -> Result { match proof_result { StateTransitionProofResult::VerifiedTokenBalance(owner_id, remaining_balance) => { Ok(BurnResult::TokenBalance(owner_id, remaining_balance)) @@ -104,3 +111,43 @@ impl Sdk { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_forward_group_burn_status_without_a_cosigner_balance() { + for status in [ + GroupActionStatus::ActionActive, + GroupActionStatus::ActionClosed, + ] { + let result = BurnResult::from_proof_result( + StateTransitionProofResult::VerifiedTokenGroupActionWithTokenBalance( + 8, status, None, + ), + ) + .expect("convert verified co-signer result"); + assert!( + matches!(result, BurnResult::GroupActionWithBalance(8, actual, None) if actual == status) + ); + } + } + + #[test] + fn should_forward_proposer_group_burn_balance_including_zero() { + for balance in [7, 0] { + let result = BurnResult::from_proof_result( + StateTransitionProofResult::VerifiedTokenGroupActionWithTokenBalance( + 3, + GroupActionStatus::ActionClosed, + Some(balance), + ), + ) + .expect("convert verified proposer result"); + assert!( + matches!(result, BurnResult::GroupActionWithBalance(3, GroupActionStatus::ActionClosed, Some(actual)) if actual == balance) + ); + } + } +} diff --git a/packages/wasm-sdk/src/state_transitions/token.rs b/packages/wasm-sdk/src/state_transitions/token.rs index 6432d9563e3..4818d5c8e56 100644 --- a/packages/wasm-sdk/src/state_transitions/token.rs +++ b/packages/wasm-sdk/src/state_transitions/token.rs @@ -517,7 +517,8 @@ pub struct TokenBurnResultWasm { #[wasm_bindgen(js_class = TokenBurnResult)] impl TokenBurnResultWasm { - /// The remaining token balance after burning. + /// The remaining token balance when supplied by the proof. + /// Group burn co-signers receive `undefined`, including after the action closes. #[wasm_bindgen(getter = "remainingBalance")] pub fn remaining_balance(&self) -> Option { self.remaining_balance.map(BigInt::from) @@ -2720,6 +2721,34 @@ impl WasmSdk { #[cfg(test)] mod tests { use super::*; + use dash_sdk::dpp::group::group_action_status::GroupActionStatus; + + #[test] + fn should_preserve_closed_group_burn_without_remaining_balance() { + let result = TokenBurnResultWasm::from_result( + BurnResult::GroupActionWithBalance(8, GroupActionStatus::ActionClosed, None), + Identifier::from([3; 32]), + ); + assert!(result.remaining_balance().is_none()); + assert_eq!(result.group_power, Some(8)); + assert_eq!(result.group_action_status.as_deref(), Some("ActionClosed")); + assert!(result.owner_id.is_none()); + } + + #[test] + fn should_preserve_proposer_group_burn_balance_including_zero() { + for balance in [7, 0] { + let result = TokenBurnResultWasm::from_result( + BurnResult::GroupActionWithBalance( + 3, + GroupActionStatus::ActionClosed, + Some(balance), + ), + Identifier::from([3; 32]), + ); + assert_eq!(result.remaining_balance, Some(balance)); + } + } /// An empty tier map is passed through as an empty schedule: rs-dpp's structure /// validation refuses it, not the SDK. @@ -2839,3 +2868,80 @@ mod tests { validate_pricing_mode_selection(false, true).expect("tiers-only selection should validate"); } } + +#[cfg(all(test, target_arch = "wasm32"))] +mod group_burn_wasm_tests { + use super::*; + use dash_sdk::dpp::group::group_action_status::GroupActionStatus; + use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; + use wasm_bindgen_test::wasm_bindgen_test; + + #[wasm_bindgen_test] + fn should_expose_undefined_remaining_balance_for_a_closed_group_burn_cosigner() { + let result = TokenBurnResultWasm::from_result( + BurnResult::GroupActionWithBalance(8, GroupActionStatus::ActionClosed, None), + Identifier::from([3; 32]), + ); + let value = JsValue::from(result); + assert!( + js_sys::Reflect::get(&value, &JsValue::from_str("remainingBalance")) + .expect("read balance getter") + .is_undefined() + ); + assert_eq!( + js_sys::Reflect::get(&value, &JsValue::from_str("groupActionStatus")) + .expect("read status getter") + .as_string() + .as_deref(), + Some("ActionClosed") + ); + } + + #[wasm_bindgen_test] + fn should_expose_proposer_group_burn_balance_including_zero_as_bigint() { + for balance in [7, 0] { + let result = TokenBurnResultWasm::from_result( + BurnResult::GroupActionWithBalance( + 3, + GroupActionStatus::ActionClosed, + Some(balance), + ), + Identifier::from([3; 32]), + ); + let value = JsValue::from(result); + let actual = js_sys::Reflect::get(&value, &JsValue::from_str("remainingBalance")) + .expect("read balance getter"); + assert!(actual.is_bigint()); + assert_eq!(actual, JsValue::from(BigInt::from(balance))); + } + } + + #[wasm_bindgen_test] + fn should_forward_closed_group_burn_without_balance_through_wasm_dpp() { + let value: JsValue = wasm_dpp2::convert_proof_result( + StateTransitionProofResult::VerifiedTokenGroupActionWithTokenBalance( + 8, + GroupActionStatus::ActionClosed, + None, + ), + ) + .expect("convert raw verifier result") + .into(); + assert!(js_sys::Reflect::get(&value, &JsValue::from_str("balance")) + .expect("read optional balance") + .is_undefined()); + assert_eq!( + js_sys::Reflect::get(&value, &JsValue::from_str("groupPower")) + .expect("read group power") + .as_f64(), + Some(8.0) + ); + assert_eq!( + js_sys::Reflect::get(&value, &JsValue::from_str("actionStatus")) + .expect("read action status") + .as_string() + .as_deref(), + Some("ActionClosed") + ); + } +} From 546c377a9a66a5bf59b7a898391db7fa70269303 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 7 Oct 2026 23:32:48 +0700 Subject: [PATCH 2/3] docs: clarify that group burn co-signers do not debit their balance Documentation only; no behavior change, so a red/green regression test is not applicable. --- packages/rs-platform-wallet-ffi/src/tokens/burn.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/tokens/burn.rs b/packages/rs-platform-wallet-ffi/src/tokens/burn.rs index 386ad767a5e..ce42605d478 100644 --- a/packages/rs-platform-wallet-ffi/src/tokens/burn.rs +++ b/packages/rs-platform-wallet-ffi/src/tokens/burn.rs @@ -16,8 +16,8 @@ use crate::runtime::block_on_worker; use crate::types::read_identifier; use crate::{unwrap_option_or_return, unwrap_result_or_return}; -/// Burn `amount` of token at `token_position` on `token_contract_id`, -/// debiting the caller's balance. +/// Burn `amount` of token at `token_position` on `token_contract_id`. +/// A group co-signer authorizes the proposal without debiting its own balance. /// /// On success, `out_balances_json` is written with a heap-allocated C /// string holding a JSON object mapping the burning identity's base58 From b79c8cfdd25332ac5341aca9f7516c72364588cb Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 7 Oct 2026 23:34:17 +0700 Subject: [PATCH 3/3] docs: explain group burn balance absence and keep test layout consistent Documentation and test module placement only; no behavior change, so an additional red/green regression test is not applicable. --- .../v0/mod.rs | 9 +++++---- packages/rs-sdk/src/platform/tokens/transitions/burn.rs | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 2a9e835cbfb..554582643fe 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -86,9 +86,6 @@ use crate::error::Error; use crate::error::proof::ProofError; use crate::query::{ContractLookupFn, SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; -#[cfg(test)] -mod group_burn_tests; - impl Drive { #[inline(always)] pub(super) fn verify_state_transition_was_executed_with_proof_v0( @@ -783,7 +780,8 @@ impl Drive { )?; // Client proof interpretation is not used by block validation or - // execution. A co-signer's balance is unrelated to the burn target. + // execution. Group burns debit the proposer recorded in the + // action, so a co-signer's balance does not describe that burn. if !group_state_transition_info.action_is_proposer { Ok(( signer_root_hash, @@ -3625,6 +3623,9 @@ fn verify_contract_document_change_execution( } } +#[cfg(test)] +mod group_burn_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-sdk/src/platform/tokens/transitions/burn.rs b/packages/rs-sdk/src/platform/tokens/transitions/burn.rs index fbc5cc3b955..a1aebfe79d4 100644 --- a/packages/rs-sdk/src/platform/tokens/transitions/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/transitions/burn.rs @@ -28,6 +28,7 @@ pub enum BurnResult { GroupActionWithDocument(GroupSumPower, Option), /// Group-based burn action with status and an optional proposer balance. /// Non-proposer co-signers receive no token balance, including for closed actions. + /// `None` means no balance was supplied, not zero. GroupActionWithBalance(GroupSumPower, GroupActionStatus, Option), }