From 7e16fa18d68852442142457e1046e65d2aa8daa0 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 7 Oct 2026 23:27:44 +0700 Subject: [PATCH 1/2] fix(platform)!: enforce token policy on document payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add document-base state_v2 for unreleased PV14: preserve payer freeze/balance precedence, then meter pause and recipient policy reads, resolving the actual issuer only for frozen recipients. Retain no-movement owner self-payments and shielded validation; freeze shipped validator generations and previous protocol tables. Test would have caught this in CI: ✖ before fix, ✔ after. The signed raw-transition policy suite had 4 passed/9 failed on the unfixed validator, including SuccessfulExecution on all seven paused-payment paths and all seven external forbidden-frozen-recipient paths. The same 13 tests pass with state_v2; all 519 surrounding document tests pass and cargo fmt --all -- --check passes. All-target all-feature drive-abci clippy is running separately. --- book/src/contract-keywords/token-cost.md | 4 + .../document_base_transaction_action/mod.rs | 13 +- .../state_v2/mod.rs | 175 ++++ .../batch/action_validation/document/mod.rs | 2 + .../batch/tests/document/mod.rs | 1 + .../batch/tests/document/token_cost_policy.rs | 961 ++++++++++++++++++ .../drive_abci_validation_versions/v10.rs | 2 +- 7 files changed, 1156 insertions(+), 2 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/state_v2/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/token_cost_policy.rs diff --git a/book/src/contract-keywords/token-cost.md b/book/src/contract-keywords/token-cost.md index d71dcdc19d7..64dee247b80 100644 --- a/book/src/contract-keywords/token-cost.md +++ b/book/src/contract-keywords/token-cost.md @@ -74,6 +74,10 @@ When the transition is processed: 3. A cost outside the signer's `minimumTokenCost` and `maximumTokenCost` is refused (`IdentityHasNotAgreedToPayRequiredTokenAmountError`, 40116). 4. The signer's gas request must be one the cost offers, and the whole batch must name one payer (40129, 40130). 5. Against state: a signer whose account for the token is frozen is refused (`IdentityTokenAccountFrozenError`, 40702), and so is one whose balance is below `amount` (`IdentityDoesNotHaveEnoughTokenBalanceError`, 40700). +6. From protocol version 14, a transparent payment that transfers or burns tokens is then refused if the token is paused (`TokenIsPausedError`, 40711). +7. For a transfer to a different identity, the contract owner's account for the token is checked next. A frozen recipient is refused (40702) only when the token issuer's `allowTransferToFrozenBalance` is `false`; its default is `true`. For another contract's token, the issuing contract's policy applies, and the recipient remains the document contract's owner. + +Every state read is charged, including on refusal. The issuer metadata and, for an external token, its contract are read only when the recipient is frozen. A transparent owner self-payment retains the payer freeze and balance checks and makes no transfer, so it performs neither the pause nor recipient checks. A [payment from the shielded pool](../data-model/token-shielded-pools.md#documents-paid-from-the-pool) retains its separate pool validation. The signer pays: the creator for a create, the owner for a replace, delete, transfer or price update, and the buyer for a purchase. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/mod.rs index b230b1c4a82..4c76c185af6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/mod.rs @@ -9,10 +9,12 @@ use crate::error::execution::ExecutionError; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::batch::action_validation::document::document_base_transaction_action::state_v0::DocumentBaseTransitionActionStateValidationV0; use crate::execution::validation::state_transition::batch::action_validation::document::document_base_transaction_action::state_v1::DocumentBaseTransitionActionStateValidationV1; +use crate::execution::validation::state_transition::batch::action_validation::document::document_base_transaction_action::state_v2::DocumentBaseTransitionActionStateValidationV2; use crate::platform_types::platform::PlatformStateRef; mod state_v0; mod state_v1; +mod state_v2; pub trait DocumentBaseTransitionActionValidation { #[allow(clippy::too_many_arguments)] @@ -64,9 +66,18 @@ impl DocumentBaseTransitionActionValidation for DocumentBaseTransitionAction { transaction, platform_version, ), + 2 => self.validate_state_v2( + platform, + owner_id, + block_info, + transition_type, + execution_context, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "DocumentBaseTransitionAction::validate_state".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/state_v2/mod.rs new file mode 100644 index 00000000000..d894e62d949 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/state_v2/mod.rs @@ -0,0 +1,175 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::batch::action_validation::document::document_base_transaction_action::state_v0::DocumentBaseTransitionActionStateValidationV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_shielded_pool_common::validate_token_not_paused; +use crate::platform_types::platform::PlatformStateRef; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::token::IdentityTokenAccountFrozenError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::identifier::Identifier; +use dpp::tokens::calculate_token_id; +use dpp::tokens::contract_info::v0::TokenContractInfoV0Accessors; +use dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; +use dpp::tokens::token_amount_on_contract_token::DocumentActionTokenEffect; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::error::drive::DriveError; +use drive::error::Error as DriveErrorWrapper; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{ + DocumentBaseTransitionAction, DocumentBaseTransitionActionAccessorsV0, +}; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentBaseTransitionActionStateValidationV2 { + #[allow(clippy::too_many_arguments)] + fn validate_state_v2( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + transition_type: &str, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentBaseTransitionActionStateValidationV2 for DocumentBaseTransitionAction { + fn validate_state_v2( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + transition_type: &str, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + // Pool payments have their own pause, balance and proof validation after the document + // action is valid; they do not spend the signer's transparent balance. + if self.shielded_token_payment().is_some() { + return Ok(SimpleConsensusValidationResult::new()); + } + let validation_result = self.validate_state_v0( + platform, + owner_id, + block_info, + transition_type, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + let Some((token_id, effect, _)) = self.token_cost() else { + return Ok(validation_result); + }; + let recipient_id = self.data_contract_fetch_info_ref().contract.owner_id(); + // Owner self-payments emit no transfer. Preserve their payer checks without adding + // movement restrictions or reads for an operation that will not happen. + if effect == DocumentActionTokenEffect::TransferTokenToContractOwner + && owner_id == recipient_id + { + return Ok(validation_result); + } + + let validation_result = validate_token_not_paused( + platform, + token_id, + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() || effect == DocumentActionTokenEffect::BurnToken { + return Ok(validation_result); + } + + let (recipient_info, fee) = platform.drive.fetch_identity_token_info_with_costs( + token_id.to_buffer(), + recipient_id.to_buffer(), + block_info, + true, + transaction, + platform_version, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + if !recipient_info.is_some_and(|info| info.frozen()) { + return Ok(validation_result); + } + + // Only a frozen recipient needs the issuer's policy. The document contract can accept + // another contract's token, so its own token settings cannot decide this restriction. + let (token_contract_info, fee) = platform.drive.fetch_token_contract_info_with_costs( + token_id.to_buffer(), + block_info, + true, + transaction, + platform_version, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + let token_contract_info = token_contract_info.ok_or_else(|| { + Error::Drive(DriveErrorWrapper::Drive(DriveError::CorruptedDriveState( + format!("contract info is missing for document payment token {token_id}"), + ))) + })?; + let token_contract_id = token_contract_info.contract_id(); + let token_contract_position = token_contract_info.token_contract_position(); + if calculate_token_id(token_contract_id.as_bytes(), token_contract_position) + != token_id.to_buffer() + { + return Err(Error::Drive(DriveErrorWrapper::Drive( + DriveError::CorruptedDriveState(format!( + "contract info does not match document payment token {token_id}" + )), + ))); + } + let token_contract = if token_contract_id == self.data_contract_id() { + self.data_contract_fetch_info() + } else { + let (fee, contract) = platform.drive.get_contract_with_fetch_info_and_fee( + token_contract_id.to_buffer(), + Some(&block_info.epoch), + false, + transaction, + platform_version, + )?; + let fee = fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "fee must exist when fetching a token's contract with an epoch", + )))?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + contract.ok_or_else(|| { + Error::Drive(DriveErrorWrapper::Drive(DriveError::CorruptedDriveState( + format!("issuer contract {token_contract_id} is missing for document payment token {token_id}"), + ))) + })? + }; + let token_configuration = token_contract + .contract + .tokens() + .get(&token_contract_position) + .ok_or_else(|| { + Error::Drive(DriveErrorWrapper::Drive(DriveError::CorruptedDriveState( + format!("issuer contract {token_contract_id} has no token at document payment position {token_contract_position}"), + ))) + })?; + if !token_configuration.is_allowed_transfer_to_frozen_balance() { + return Ok(SimpleConsensusValidationResult::new_with_error( + IdentityTokenAccountFrozenError::new( + token_id, + recipient_id, + format!("Document {} token payment", transition_type), + ) + .into(), + )); + } + Ok(validation_result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/mod.rs index e3a5712fb14..da2b80350ff 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/mod.rs @@ -1,4 +1,6 @@ mod document_base_transaction_action; +#[cfg(test)] +pub(crate) use document_base_transaction_action::DocumentBaseTransitionActionValidation; pub(crate) mod document_create_transition_action; pub(crate) mod document_delete_transition_action; pub(crate) mod document_index_only_delete_transition_action; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 398f28c47f5..9c5c35e856b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -32,6 +32,7 @@ mod reference_test_setup; mod replacement; mod required_since; mod system_agreement; +mod token_cost_policy; mod transfer; mod typed_array_references; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/token_cost_policy.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/token_cost_policy.rs new file mode 100644 index 00000000000..e883bb0beb7 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/token_cost_policy.rs @@ -0,0 +1,961 @@ +//! Transparent document token payments obey the token issuer's movement policy. + +use super::*; +use super::gas_sponsorship::gas_sponsorship_tests::Sponsorship; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0}; +use crate::execution::validation::state_transition::batch::action_validation::document::DocumentBaseTransitionActionValidation; +use crate::platform_types::platform::PlatformStateRef; +use dpp::consensus::codes::ErrorWithCode; +use dpp::data_contract::accessors::v1::DataContractV1Setters; +use dpp::data_contract::accessors::v0::DataContractV0Setters; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Setters; +use dpp::data_contract::{DataContract, document_type::DocumentType}; +use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; +use dpp::platform_value::platform_value; +use dpp::state_transition::StateTransition; +use dpp::tokens::calculate_token_id; +use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; +use dpp::tokens::status::{v0::TokenStatusV0, TokenStatus}; +use dpp::tokens::token_amount_on_contract_token::DocumentActionTokenEffect; +use dpp::tokens::token_payment_info::{TokenPaymentInfo, v0::TokenPaymentInfoV0}; +use dpp::tokens::contract_info::v0::TokenContractInfoV0Accessors; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::DefaultForPlatformVersion; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; +use drive::grovedb::Transaction; +use drive::util::test_helpers::setup_contract; + +fn payment_setup(platform_version: &'static PlatformVersion) -> Sponsorship { + policy_setup( + platform_version, + DocumentActionTokenEffect::TransferTokenToContractOwner, + Some(false), + false, + false, + ) +} + +fn policy_setup( + platform_version: &'static PlatformVersion, + effect: DocumentActionTokenEffect, + allow_frozen: Option, + external: bool, + optional: bool, +) -> Sponsorship { + let issuer_id = + external.then(|| DataContract::generate_data_contract_id_v0(Identifier::new([9; 32]), 1)); + let setup = Sponsorship::build_customized( + platform_version, + GasFeesPaidBy::DocumentOwner, + optional, + dash_to_credits!(0.5), + dash_to_credits!(0.5), + 15, + None, + |contract| { + if external { + contract + .tokens_mut() + .expect("tokens") + .get_mut(&0) + .expect("gold") + .allow_transfer_to_frozen_balance(false); + } + if !external { + if let Some(allow) = allow_frozen { + contract + .tokens_mut() + .expect("tokens") + .get_mut(&0) + .expect("gold") + .allow_transfer_to_frozen_balance(allow); + } + } + let mut cost = platform_value!({"tokenPosition": 0u16, "amount": 10u64}); + cost.insert("effect".into(), u8::from(effect).into()) + .expect("effect"); + if let Some(issuer_id) = issuer_id { + cost.insert("contractId".into(), issuer_id.into()) + .expect("issuer"); + } + if optional { + cost.insert("optional".into(), true.into()) + .expect("optional"); + } + let mut costs = platform_value!({}); + for action in [ + "create", + "replace", + "delete", + "transfer", + "update_price", + "purchase", + ] { + costs + .insert(action.into(), cost.clone()) + .expect("action cost"); + } + let mut schema = contract + .document_type_for_name("card") + .expect("card") + .schema() + .clone(); + schema + .insert("tokenCost".into(), costs) + .expect("token costs"); + let card = DocumentType::try_from_schema( + contract.id(), + 1, + contract.config().version(), + "card", + schema, + None, + contract.tokens(), + contract.config(), + true, + &mut vec![], + platform_version, + ) + .expect("card schema"); + contract.document_types_mut().insert("card".into(), card); + if platform_version.protocol_version >= 14 { + let entry_schema = platform_value!({ + "type": "object", "indexOnly": true, "documentsMutable": false, + "canBeDeleted": true, + "properties": {"name": {"type": "string", "maxLength": 63, "position": 0u16}}, + "indices": [{"name": "byName", "properties": [{"name": "asc"}], "terminal": "$ownerId"}], + "required": ["name"], "additionalProperties": false, + "tokenCost": {"delete": cost} + }); + let entry = DocumentType::try_from_schema( + contract.id(), + 1, + contract.config().version(), + "entry", + entry_schema, + None, + contract.tokens(), + contract.config(), + true, + &mut vec![], + platform_version, + ) + .expect("index-only schema"); + contract.document_types_mut().insert("entry".into(), entry); + } + }, + ); + if let Some(issuer_id) = issuer_id { + setup_contract( + &setup.platform.drive, + "tests/supporting_files/contract/crypto-card-game/crypto-card-game-in-game-currency.json", + Some(issuer_id.to_buffer()), Some([9; 32]), + Some(|contract: &mut DataContract| { + contract.set_created_at_epoch(Some(0)); + contract.set_created_at(Some(0)); + contract.set_created_at_block_height(Some(0)); + if let Some(allow) = allow_frozen { + contract.tokens_mut().expect("tokens").get_mut(&0).expect("gold") + .allow_transfer_to_frozen_balance(allow); + } + }), None, Some(platform_version), + ); + add_tokens_to_identity(&setup.platform, token_id(&setup), setup.user.id(), 15); + } + setup +} + +fn token_id(setup: &Sponsorship) -> Identifier { + let cost = setup + .contract + .document_type_for_name("card") + .expect("card") + .document_creation_token_cost() + .expect("cost"); + calculate_token_id( + cost.contract_id.unwrap_or(setup.contract.id()).as_bytes(), + 0, + ) + .into() +} + +fn payment_info(setup: &Sponsorship) -> TokenPaymentInfo { + TokenPaymentInfo::V0(TokenPaymentInfoV0 { + payment_token_contract_id: setup + .contract + .document_type_for_name("card") + .expect("card") + .document_creation_token_cost() + .expect("cost") + .contract_id, + token_contract_position: 0, + minimum_token_cost: None, + maximum_token_cost: Some(10), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + }) +} + +async fn creation(setup: &Sponsorship, pay_token: bool) -> StateTransition { + let (document, entropy) = setup.card_of(&setup.user); + BatchTransition::new_document_creation_transition_from_document( + document, + setup.contract.document_type_for_name("card").expect("card"), + entropy.0, + &setup.user_key, + 2, + 0, + pay_token.then(|| payment_info(setup)), + &setup.user_signer, + setup.platform_version, + None, + ) + .await + .expect("create transition") +} + +async fn prepared_action(setup: &mut Sponsorship, action: &str) -> StateTransition { + if action == "create" { + return creation(setup, true).await; + } + let (other, other_signer, other_key) = + setup_identity(&mut setup.platform, 450, dash_to_credits!(0.5)); + let creator = if action == "purchase" { + &other + } else { + &setup.user + }; + let creator_key = if action == "purchase" { + &other_key + } else { + &setup.user_key + }; + let creator_signer = if action == "purchase" { + &other_signer + } else { + &setup.user_signer + }; + add_tokens_to_identity(&setup.platform, token_id(setup), creator.id(), 100); + let (mut document, entropy) = setup.card_of(creator); + let type_name = if action == "index_only_delete" { + "entry" + } else { + "card" + }; + let document_type = setup + .contract + .document_type_for_name(type_name) + .expect("document type"); + if action == "index_only_delete" { + document = document_type + .random_document_with_identifier_and_entropy( + &mut StdRng::seed_from_u64(433), + creator.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + setup.platform_version, + ) + .expect("entry document"); + document.set("name", "entry".into()); + document + .set_id_for_creation(document_type, &entropy.0, 2, setup.platform_version) + .expect("entry id"); + } + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + creator_key, + 2, + 0, + (action != "index_only_delete").then(|| payment_info(setup)), + creator_signer, + setup.platform_version, + None, + ) + .await + .expect("setup creation"); + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&create, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + setup + .platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .expect("commit setup creation"); + document.set_revision(Some(2)); + let payment = Some(payment_info(setup)); + match action { + "replace" => { + document.set("attack", 5.into()); + BatchTransition::new_document_replacement_transition_from_document( + document, + document_type, + &setup.user_key, + 3, + 0, + payment, + &setup.user_signer, + setup.platform_version, + None, + ) + .await + } + "delete" | "index_only_delete" => { + BatchTransition::new_document_deletion_transition_from_document( + document, + document_type, + &setup.user_key, + 3, + 0, + payment, + &setup.user_signer, + setup.platform_version, + None, + ) + .await + } + "transfer" => { + BatchTransition::new_document_transfer_transition_from_document( + document, + document_type, + other.id(), + &setup.user_key, + 3, + 0, + payment, + &setup.user_signer, + setup.platform_version, + None, + ) + .await + } + "update_price" => { + BatchTransition::new_document_update_price_transition_from_document( + document, + document_type, + 1000, + &setup.user_key, + 3, + 0, + payment, + &setup.user_signer, + setup.platform_version, + None, + ) + .await + } + "purchase" => { + let price = BatchTransition::new_document_update_price_transition_from_document( + document.clone(), + document_type, + 1000, + &other_key, + 3, + 0, + Some(payment_info(setup)), + &other_signer, + setup.platform_version, + None, + ) + .await + .expect("setup price"); + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&price, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + setup + .platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .expect("commit setup price"); + document.set_revision(Some(3)); + BatchTransition::new_document_purchase_transition_from_document( + document, + document_type, + setup.user.id(), + 1000, + &setup.user_key, + 2, + 0, + payment, + &setup.user_signer, + setup.platform_version, + None, + ) + .await + } + _ => panic!("unknown document action {action}"), + } + .expect("document action") +} + +fn balance(setup: &Sponsorship, holder: Identifier, tx: &Transaction) -> u64 { + setup + .platform + .drive + .fetch_identity_token_balance( + token_id(setup).to_buffer(), + holder.to_buffer(), + Some(tx), + setup.platform_version, + ) + .expect("balance") + .unwrap_or_default() +} + +fn pause(setup: &Sponsorship, tx: Option<&Transaction>) { + setup + .platform + .drive + .token_apply_status( + token_id(setup).to_buffer(), + TokenStatus::V0(TokenStatusV0 { paused: true }), + &BlockInfo::default(), + true, + tx, + setup.platform_version, + ) + .expect("pause token"); +} + +fn freeze(setup: &Sponsorship, holder: Identifier) { + setup + .platform + .drive + .token_freeze( + token_id(setup), + holder, + &BlockInfo::default(), + true, + None, + setup.platform_version, + ) + .expect("freeze account"); +} + +fn assert_no_card(setup: &Sponsorship, tx: &Transaction) { + let query = DriveDocumentQuery::from_sql_expr( + "select * from card", + &setup.contract, + Some(&setup.platform.config.drive), + setup.platform_version, + ) + .expect("query"); + let documents = setup + .platform + .drive + .query_documents(query, None, false, Some(tx), None) + .expect("documents"); + assert!(documents.documents().is_empty()); + assert_eq!(balance(setup, setup.user.id(), tx), 15); + assert_eq!(balance(setup, setup.contract_owner.id(), tx), 0); + assert_eq!( + setup + .platform + .drive + .fetch_token_total_supply( + token_id(setup).to_buffer(), + Some(tx), + setup.platform_version + ) + .expect("supply"), + Some(15) + ); +} + +fn assert_paid_error(result: &StateTransitionExecutionResult, code: u32) { + assert_matches!(result, PaidConsensusError { error, actual_fees, .. } + if error.code() == code && actual_fees.processing_fee > 0); +} + +fn validation_operations( + setup: &Sponsorship, + payer: Identifier, + tx: &Transaction, +) -> (SimpleConsensusValidationResult, Vec) { + let (_, contract) = setup + .platform + .drive + .get_contract_with_fetch_info_and_fee( + setup.contract.id().to_buffer(), + Some(&BlockInfo::default().epoch), + false, + Some(tx), + setup.platform_version, + ) + .expect("document contract"); + let cost = setup + .contract + .document_type_for_name("card") + .expect("card") + .document_creation_token_cost() + .expect("cost"); + let base: DocumentBaseTransitionAction = DocumentBaseTransitionActionV0 { + id: Identifier::new([7; 32]), + identity_contract_nonce: 2, + document_type_name: "card".into(), + data_contract: contract.expect("contract exists"), + token_cost: Some((token_id(setup), cost.effect, cost.token_amount)), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + contract_gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + declared_action_fee: None, + shielded_token_payment: None, + } + .into(); + let state = setup.platform.state.load(); + let platform = PlatformStateRef { + drive: &setup.platform.drive, + state: &state, + config: &setup.platform.config, + }; + let mut context = + StateTransitionExecutionContext::default_for_platform_version(setup.platform_version) + .expect("context"); + let result = base + .validate_state( + &platform, + payer, + &BlockInfo::default(), + "create", + &mut context, + Some(tx), + setup.platform_version, + ) + .expect("validation"); + (result, context.operations_consume()) +} + +#[test] +fn should_bill_each_document_payment_policy_read_once_in_execution_order() { + for external in [false, true] { + for frozen in [false, true] { + let setup = policy_setup( + PlatformVersion::latest(), + Default::default(), + Some(false), + external, + false, + ); + if frozen { + freeze(&setup, setup.contract_owner.id()); + } + let tx = setup.platform.drive.grove.start_transaction(); + let drive = &setup.platform.drive; + let pv = setup.platform_version; + let block = BlockInfo::default(); + let token = token_id(&setup).to_buffer(); + let payer = setup.user.id().to_buffer(); + let recipient = setup.contract_owner.id().to_buffer(); + let mut expected = vec![ + ValidationOperation::PrecalculatedOperation( + drive + .fetch_identity_token_info_with_costs( + token, + payer, + &block, + true, + Some(&tx), + pv, + ) + .expect("payer info") + .1, + ), + ValidationOperation::PrecalculatedOperation( + drive + .fetch_identity_token_balance_with_costs( + token, + payer, + &block, + true, + Some(&tx), + pv, + ) + .expect("payer balance") + .1, + ), + ValidationOperation::PrecalculatedOperation( + drive + .fetch_token_status_with_costs(token, &block, true, Some(&tx), pv) + .expect("pause") + .1, + ), + ValidationOperation::PrecalculatedOperation( + drive + .fetch_identity_token_info_with_costs( + token, + recipient, + &block, + true, + Some(&tx), + pv, + ) + .expect("recipient info") + .1, + ), + ]; + if frozen { + let (info, fee) = drive + .fetch_token_contract_info_with_costs(token, &block, true, Some(&tx), pv) + .expect("issuer metadata"); + expected.push(ValidationOperation::PrecalculatedOperation(fee)); + if external { + let (fee, _) = drive + .get_contract_with_fetch_info_and_fee( + info.expect("metadata").contract_id().to_buffer(), + Some(&block.epoch), + false, + Some(&tx), + pv, + ) + .expect("external issuer"); + expected.push(ValidationOperation::PrecalculatedOperation( + fee.expect("issuer read fee"), + )); + } + } + let (result, operations) = validation_operations(&setup, setup.user.id(), &tx); + assert_eq!(operations, expected, "external={external}, frozen={frozen}"); + assert_eq!(result.is_valid(), !frozen); + } + } +} + +#[test] +fn should_skip_new_policy_reads_for_elided_owner_self_payment() { + let setup = payment_setup(PlatformVersion::latest()); + add_tokens_to_identity( + &setup.platform, + token_id(&setup), + setup.contract_owner.id(), + 15, + ); + pause(&setup, None); + let tx = setup.platform.drive.grove.start_transaction(); + let (result, operations) = validation_operations(&setup, setup.contract_owner.id(), &tx); + assert!(result.is_valid()); + assert_eq!(operations.len(), 2, "only payer info and balance are read"); +} + +#[tokio::test] +async fn should_reject_document_token_payment_while_token_is_paused() { + for external in [false, true] { + let setup = policy_setup( + PlatformVersion::latest(), + Default::default(), + Some(false), + external, + false, + ); + pause(&setup, None); + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + let result = setup.process(&transition, &tx); + assert_paid_error(&result, 40711); + assert_no_card(&setup, &tx); + } +} + +#[tokio::test] +async fn should_reject_document_token_payment_to_frozen_contract_owner_when_issuer_forbids_it() { + for external in [false, true] { + let setup = policy_setup( + PlatformVersion::latest(), + Default::default(), + Some(false), + external, + false, + ); + freeze(&setup, setup.contract_owner.id()); + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + let result = setup.process(&transition, &tx); + assert_paid_error(&result, 40702); + assert_matches!(result, PaidConsensusError { error: ConsensusError::StateError(StateError::IdentityTokenAccountFrozenError(ref error)), .. } + if *error.identity_id() == setup.contract_owner.id() && *error.token_id() == token_id(&setup)); + assert_no_card(&setup, &tx); + } +} + +#[tokio::test] +async fn should_allow_frozen_recipient_when_actual_issuer_uses_default_true() { + for external in [false, true] { + let setup = policy_setup( + PlatformVersion::latest(), + Default::default(), + None, + external, + false, + ); + freeze(&setup, setup.contract_owner.id()); + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + let result = setup.process(&transition, &tx); + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(balance(&setup, setup.user.id(), &tx), 5); + assert_eq!(balance(&setup, setup.contract_owner.id(), &tx), 10); + } +} + +#[tokio::test] +async fn should_allow_optional_credit_payment_while_explicit_token_payment_is_restricted() { + let setup = policy_setup( + PlatformVersion::latest(), + Default::default(), + Some(false), + true, + true, + ); + pause(&setup, None); + freeze(&setup, setup.contract_owner.id()); + let explicit = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + assert_paid_error(&setup.process(&explicit, &tx), 40711); + // Each transaction starts at the same contract nonce so these are independent choices. + drop(tx); + let credits = creation(&setup, false).await; + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&credits, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(balance(&setup, setup.user.id(), &tx), 15); + assert_eq!(balance(&setup, setup.contract_owner.id(), &tx), 0); +} + +#[tokio::test] +async fn should_pause_document_burn_payments_without_changing_native_burn_policy() { + let setup = policy_setup( + PlatformVersion::latest(), + DocumentActionTokenEffect::BurnToken, + Some(false), + false, + false, + ); + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&transition, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(balance(&setup, setup.user.id(), &tx), 5); + assert_eq!( + setup + .platform + .drive + .fetch_token_total_supply( + token_id(&setup).to_buffer(), + Some(&tx), + setup.platform_version + ) + .expect("supply"), + Some(5) + ); + drop(tx); + pause(&setup, None); + let tx = setup.platform.drive.grove.start_transaction(); + assert_paid_error(&setup.process(&transition, &tx), 40711); + assert_no_card(&setup, &tx); +} + +#[tokio::test] +async fn should_preserve_elided_owner_self_payment_while_token_is_paused() { + let setup = payment_setup(PlatformVersion::latest()); + add_tokens_to_identity( + &setup.platform, + token_id(&setup), + setup.contract_owner.id(), + 15, + ); + pause(&setup, None); + let transition = setup + .card_creation_by_the_contract_owner(GasFeesPaidBy::DocumentOwner) + .await; + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&transition, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(balance(&setup, setup.contract_owner.id(), &tx), 15); + assert_eq!( + setup + .platform + .drive + .fetch_token_total_supply( + token_id(&setup).to_buffer(), + Some(&tx), + setup.platform_version + ) + .expect("supply"), + Some(30) + ); +} + +#[tokio::test] +async fn should_preserve_paused_and_frozen_recipient_payments_at_previous_protocol() { + let setup = payment_setup(PlatformVersion::get(13).expect("previous protocol")); + pause(&setup, None); + freeze(&setup, setup.contract_owner.id()); + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&transition, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(balance(&setup, setup.user.id(), &tx), 5); + assert_eq!(balance(&setup, setup.contract_owner.id(), &tx), 10); +} + +#[tokio::test] +async fn should_reject_paused_payments_on_all_seven_document_action_paths() { + let mut failures = vec![]; + for action in [ + "create", + "replace", + "delete", + "transfer", + "update_price", + "purchase", + "index_only_delete", + ] { + let mut setup = payment_setup(PlatformVersion::latest()); + let transition = prepared_action(&mut setup, action).await; + pause(&setup, None); + let tx = setup.platform.drive.grove.start_transaction(); + let before_payer = balance(&setup, setup.user.id(), &tx); + let before_recipient = balance(&setup, setup.contract_owner.id(), &tx); + let result = setup.process(&transition, &tx); + if !matches!(&result, PaidConsensusError { error, .. } if error.code() == 40711) { + failures.push((action, result)); + continue; + } + assert_eq!( + balance(&setup, setup.user.id(), &tx), + before_payer, + "{action}" + ); + assert_eq!( + balance(&setup, setup.contract_owner.id(), &tx), + before_recipient, + "{action}" + ); + } + assert!( + failures.is_empty(), + "paused token payments accepted or misclassified: {failures:?}" + ); +} + +#[tokio::test] +async fn should_reject_external_issuer_frozen_recipient_on_all_seven_document_action_paths() { + let mut failures = vec![]; + for action in [ + "create", + "replace", + "delete", + "transfer", + "update_price", + "purchase", + "index_only_delete", + ] { + let mut setup = policy_setup( + PlatformVersion::latest(), + Default::default(), + Some(false), + true, + false, + ); + let transition = prepared_action(&mut setup, action).await; + freeze(&setup, setup.contract_owner.id()); + let tx = setup.platform.drive.grove.start_transaction(); + let before_payer = balance(&setup, setup.user.id(), &tx); + let before_recipient = balance(&setup, setup.contract_owner.id(), &tx); + let result = setup.process(&transition, &tx); + if !matches!(&result, PaidConsensusError { error, .. } if error.code() == 40702) { + failures.push((action, result)); + continue; + } + assert_eq!( + balance(&setup, setup.user.id(), &tx), + before_payer, + "{action}" + ); + assert_eq!( + balance(&setup, setup.contract_owner.id(), &tx), + before_recipient, + "{action}" + ); + } + assert!( + failures.is_empty(), + "frozen recipient token payments accepted or misclassified: {failures:?}" + ); +} + +#[tokio::test] +async fn should_preserve_payer_freeze_and_balance_precedence_before_pause_and_recipient() { + for payer_frozen in [false, true] { + let setup = payment_setup(PlatformVersion::latest()); + setup + .platform + .drive + .token_burn( + token_id(&setup).to_buffer(), + setup.user.id().to_buffer(), + 15, + &BlockInfo::default(), + true, + None, + setup.platform_version, + ) + .expect("empty payer balance"); + pause(&setup, None); + freeze(&setup, setup.contract_owner.id()); + if payer_frozen { + freeze(&setup, setup.user.id()); + } + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + let result = setup.process(&transition, &tx); + assert_paid_error(&result, if payer_frozen { 40702 } else { 40700 }); + if payer_frozen { + assert_matches!(result, PaidConsensusError { error: ConsensusError::StateError(StateError::IdentityTokenAccountFrozenError(ref error)), .. } + if *error.identity_id() == setup.user.id()); + } + let (_, operations) = validation_operations(&setup, setup.user.id(), &tx); + assert_eq!(operations.len(), if payer_frozen { 1 } else { 2 }); + } + let setup = payment_setup(PlatformVersion::latest()); + pause(&setup, None); + freeze(&setup, setup.contract_owner.id()); + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + assert_paid_error(&setup.process(&transition, &tx), 40711); + let (_, operations) = validation_operations(&setup, setup.user.id(), &tx); + assert_eq!( + operations.len(), + 3, + "pause refusal precedes recipient lookup" + ); +} + +#[tokio::test] +async fn should_read_pause_written_in_the_same_transaction_as_document_payment() { + let setup = payment_setup(PlatformVersion::latest()); + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + pause(&setup, Some(&tx)); + assert_paid_error(&setup.process(&transition, &tx), 40711); + assert_no_card(&setup, &tx); +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 74966d12af9..53a6d905716 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -259,7 +259,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = document_transfer_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom: $ownerId` judgement in place, inert before this version document_purchase_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom: $ownerId` judgement in place, inert before this version document_update_price_transition_structure_validation: 0, - document_base_transition_state_validation: 1, // changed: a document token cost may be paid from the token shielded pool + document_base_transition_state_validation: 2, // changed: transparent document token payments enforce pause and the issuer's frozen-recipient policy; pool payments retain separate validation document_create_transition_state_validation: 2, document_delete_transition_state_validation: 0, document_index_only_delete_transition_state_validation: 0, From 2a6a0343c435b51a5884c912de014154a6eff462 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 7 Oct 2026 23:51:47 +0700 Subject: [PATCH 2/2] test(platform): pin document burn payment policy Document PV14 state validation 2 in entry 84 and qualify the token-cost book behavior. Add owner burn and frozen-owner recipient controls, with opposing external issuer fixtures in both directions. Test would have caught this in CI: removing the owner transfer-effect guard failed the owner burn test (0 passed, 1 failed); removing the burn early return failed the frozen-owner burn test (0 passed, 1 failed). Restoring the validator makes all 15 focused tests pass. Final fmt and clippy pass; production logic is unchanged in this follow-up. --- book/src/contract-keywords/token-cost.md | 4 +- .../state_v2/mod.rs | 4 + .../batch/tests/document/token_cost_policy.rs | 110 +++++++++++++++++- .../rs-platform-version/src/version/v14.rs | 16 ++- 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/book/src/contract-keywords/token-cost.md b/book/src/contract-keywords/token-cost.md index 64dee247b80..94db0485ec3 100644 --- a/book/src/contract-keywords/token-cost.md +++ b/book/src/contract-keywords/token-cost.md @@ -75,9 +75,9 @@ When the transition is processed: 4. The signer's gas request must be one the cost offers, and the whole batch must name one payer (40129, 40130). 5. Against state: a signer whose account for the token is frozen is refused (`IdentityTokenAccountFrozenError`, 40702), and so is one whose balance is below `amount` (`IdentityDoesNotHaveEnoughTokenBalanceError`, 40700). 6. From protocol version 14, a transparent payment that transfers or burns tokens is then refused if the token is paused (`TokenIsPausedError`, 40711). -7. For a transfer to a different identity, the contract owner's account for the token is checked next. A frozen recipient is refused (40702) only when the token issuer's `allowTransferToFrozenBalance` is `false`; its default is `true`. For another contract's token, the issuing contract's policy applies, and the recipient remains the document contract's owner. +7. From protocol version 14, for a transfer to a different identity, the contract owner's account for the token is checked next. A frozen recipient is refused (40702) only when the token issuer's `allowTransferToFrozenBalance` is `false`; its default is `true`. For another contract's token, the issuing contract's policy applies, and the recipient remains the document contract's owner. -Every state read is charged, including on refusal. The issuer metadata and, for an external token, its contract are read only when the recipient is frozen. A transparent owner self-payment retains the payer freeze and balance checks and makes no transfer, so it performs neither the pause nor recipient checks. A [payment from the shielded pool](../data-model/token-shielded-pools.md#documents-paid-from-the-pool) retains its separate pool validation. +Every state read is charged, including on refusal. From protocol version 14, the issuer metadata and, for an external token, its contract are read only when the recipient is frozen. A transparent owner self-payment retains the payer freeze and balance checks and makes no transfer, so it performs neither the pause nor recipient checks. A [payment from the shielded pool](../data-model/token-shielded-pools.md#documents-paid-from-the-pool) retains its separate pool validation. The signer pays: the creator for a create, the owner for a replace, delete, transfer or price update, and the buyer for a purchase. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/state_v2/mod.rs index d894e62d949..3e642f6e31d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_base_transaction_action/state_v2/mod.rs @@ -41,6 +41,10 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: } impl DocumentBaseTransitionActionStateValidationV2 for DocumentBaseTransitionAction { + /// Version 2 (protocol version 14): transparent payments retain payer freeze and balance + /// checks, then enforce pause and the actual issuer's frozen-recipient policy for token + /// movements. Elided owner self-payments retain only the payer checks; pool payments + /// retain their separate shielded validation. fn validate_state_v2( &self, platform: &PlatformStateRef, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/token_cost_policy.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/token_cost_policy.rs index e883bb0beb7..d98fd5783d1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/token_cost_policy.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/token_cost_policy.rs @@ -55,12 +55,13 @@ fn policy_setup( None, |contract| { if external { + // Opposing policies distinguish the issuer from the document contract's token. contract .tokens_mut() .expect("tokens") .get_mut(&0) .expect("gold") - .allow_transfer_to_frozen_balance(false); + .allow_transfer_to_frozen_balance(allow_frozen == Some(false)); } if !external { if let Some(allow) = allow_frozen { @@ -735,7 +736,7 @@ async fn should_allow_optional_credit_payment_while_explicit_token_payment_is_re } #[tokio::test] -async fn should_pause_document_burn_payments_without_changing_native_burn_policy() { +async fn should_reject_paused_document_burn_payments_and_allow_active_burn() { let setup = policy_setup( PlatformVersion::latest(), DocumentActionTokenEffect::BurnToken, @@ -802,6 +803,111 @@ async fn should_preserve_elided_owner_self_payment_while_token_is_paused() { ); } +#[tokio::test] +async fn should_burn_the_contract_owners_tokens_and_refuse_the_same_payment_when_paused() { + let setup = policy_setup( + PlatformVersion::latest(), + DocumentActionTokenEffect::BurnToken, + Some(false), + false, + false, + ); + add_tokens_to_identity( + &setup.platform, + token_id(&setup), + setup.contract_owner.id(), + 15, + ); + let transition = setup + .card_creation_by_the_contract_owner(GasFeesPaidBy::DocumentOwner) + .await; + let tx = setup.platform.drive.grove.start_transaction(); + let (result, operations) = validation_operations(&setup, setup.contract_owner.id(), &tx); + assert!(result.is_valid()); + assert_eq!( + operations.len(), + 3, + "a real owner burn reads payer info, balance and pause" + ); + assert_matches!( + setup.process(&transition, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(balance(&setup, setup.contract_owner.id(), &tx), 5); + assert_eq!( + setup + .platform + .drive + .fetch_token_total_supply( + token_id(&setup).to_buffer(), + Some(&tx), + setup.platform_version + ) + .expect("supply"), + Some(20) + ); + drop(tx); + + pause(&setup, None); + let tx = setup.platform.drive.grove.start_transaction(); + assert_paid_error(&setup.process(&transition, &tx), 40711); + let (result, operations) = validation_operations(&setup, setup.contract_owner.id(), &tx); + assert!(!result.is_valid()); + assert_eq!(operations.len(), 3, "owner burns retain the pause read"); + assert_eq!(balance(&setup, setup.contract_owner.id(), &tx), 15); + assert_eq!( + setup + .platform + .drive + .fetch_token_total_supply( + token_id(&setup).to_buffer(), + Some(&tx), + setup.platform_version + ) + .expect("supply"), + Some(30) + ); +} + +#[tokio::test] +async fn should_burn_document_payment_without_recipient_reads_when_contract_owner_is_frozen() { + let setup = policy_setup( + PlatformVersion::latest(), + DocumentActionTokenEffect::BurnToken, + Some(false), + false, + false, + ); + freeze(&setup, setup.contract_owner.id()); + let transition = creation(&setup, true).await; + let tx = setup.platform.drive.grove.start_transaction(); + let (result, operations) = validation_operations(&setup, setup.user.id(), &tx); + assert!(result.is_valid(), "a burn has no recipient to freeze"); + assert_eq!( + operations.len(), + 3, + "burns read only payer info, balance and pause" + ); + assert_matches!( + setup.process(&transition, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(balance(&setup, setup.user.id(), &tx), 5); + assert_eq!(balance(&setup, setup.contract_owner.id(), &tx), 0); + assert_eq!( + setup + .platform + .drive + .fetch_token_total_supply( + token_id(&setup).to_buffer(), + Some(&tx), + setup.platform_version + ) + .expect("supply"), + Some(5) + ); +} + #[tokio::test] async fn should_preserve_paused_and_frozen_recipient_payments_at_previous_protocol() { let setup = payment_setup(PlatformVersion::get(13).expect("previous protocol")); diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 8b1e9a89919..6b991d61888 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2152,6 +2152,20 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// documents. A stored contract still parses. No earlier version parses /// `ttl`. /// +/// 84. **Document token payments obey the issuer's movement policy**: +/// document-base state validation 2 supersedes 1 +/// (`DRIVE_ABCI_VALIDATION_VERSIONS_V10`). A transparent transfer or burn +/// payment of a paused token is refused (`TokenIsPausedError`, 40711). +/// A transfer to a frozen document contract owner is refused +/// (`IdentityTokenAccountFrozenError`, 40702, naming that owner) only when +/// the token issuer's `allowTransferToFrozenBalance` is false; its default +/// is true, and external issuers are included. Reads are billed in order: +/// payer freeze, payer balance, pause, recipient info, then issuer metadata +/// only if frozen, and the issuer contract only when external. Owner +/// self-payments emit no transfer and retain only their payer checks. +/// Shielded payments retain their separate pool validation; native burn +/// policy and earlier protocol tables are unchanged. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -2237,7 +2251,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, // changed: saved platform state structure 1 keeps masternodes and validator sets as one aux entry each methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit; record_token_shielded_pool_anchors records and prunes the anchors of the token pools a block touched; decode_raw_state_transitions, execute_event, validate_fees_of_event and add_distribute_storage_fee_to_epochs_operations each move to 1 — the table's own per-slot comments carry the full list - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; a contest accepts at most max_contenders_per_contest contenders and maximum_contenders_to_consider rises to 10,000; a contender's fund doubles past 250 contenders and for every 50 more; the three shielded-fee token pool transitions gain basic structure validation and document_base_transition_state_validation 1 admits a document token cost paid from a token pool; the ShieldFromAssetLock transform_into_action 1 checks its bundle against the bound preimage + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; a contest accepts at most max_contenders_per_contest contenders and maximum_contenders_to_consider rises to 10,000; a contender's fund doubles past 250 contenders and for every 50 more; the three shielded-fee token pool transitions gain basic structure validation and document_base_transition_state_validation 2 admits a document token cost paid from a token pool and enforces pause and the issuer's frozen-recipient policy on transparent payments; the ShieldFromAssetLock transform_into_action 1 checks its bundle against the bound preimage withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history query: DRIVE_ABCI_QUERY_VERSIONS_V2, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1,