From 25143a4029997d24637cc8b84cc1005abc9b7980 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 05:32:00 +0200 Subject: [PATCH 1/2] feat(platform)!: non-transferable tokens (PV14) A token configuration of format 1 gains `transferable`, true when absent and fixed at creation. A non-transferable token cannot change hands: - a TokenTransfer is refused (TokenNotTransferableError, 40726) in batch advanced structure validation 1, so the mempool refuses it too; - a document type of its own contract must burn it rather than pay the contract owner (NonTransferableTokenPaymentMustBurnError, 10280); - another contract's document type cannot charge it (40726 at contract create and update, reusing the read that checks the token exists); - it cannot have a shielded pool (NonTransferableTokenShieldedPoolError, 10279), since unshielding can pay any identity. Mints, claims, direct purchases, burns, freezes, pause and burn payments for its own documents are unchanged. Yappr needs this for YAPP: pausing it forever also refuses its document payments from protocol version 14, and before 14 never stopped transfers routed through a contract. The parser, validate_shielded_pool_rules and contract create/update state validation 0 are edited in place: only a format 1 configuration can be non-transferable, and the pre-activation gate refuses that format before protocol version 14. Co-Authored-By: Claude Opus 5.5 --- book/src/SUMMARY.md | 1 + book/src/contract-keywords/token-cost.md | 7 +- .../src/data-model/non-transferable-tokens.md | 63 ++++ book/src/data-model/token-shielded-pools.md | 6 +- .../token_configuration/accessors/mod.rs | 59 +++- .../token_configuration/accessors/v1/mod.rs | 11 +- .../token_configuration/mod.rs | 164 ++++++++++- .../token_configuration/v1/accessors/mod.rs | 8 + .../token_configuration/v1/mod.rs | 31 +- .../try_from_schema/common/mod.rs | 30 +- .../class_methods/try_from_schema/v3/mod.rs | 2 + .../v3/non_transferable_token_cost_tests.rs | 98 ++++++ .../methods/validate_update/v0/mod.rs | 4 +- .../src/errors/consensus/basic/basic_error.rs | 30 +- .../consensus/basic/data_contract/mod.rs | 4 + ...nsferable_token_payment_must_burn_error.rs | 64 ++++ ..._transferable_token_shielded_pool_error.rs | 57 ++++ packages/rs-dpp/src/errors/consensus/codes.rs | 3 + .../src/errors/consensus/state/state_error.rs | 14 +- .../src/errors/consensus/state/token/mod.rs | 2 + .../token/token_not_transferable_error.rs | 57 ++++ .../batch/advanced_structure/v1/mod.rs | 31 +- .../batch/tests/token/mod.rs | 1 + .../batch/tests/token/non_transferable/mod.rs | 278 ++++++++++++++++++ .../data_contract_create/mod.rs | 253 ++++++++++++++++ .../data_contract_create/state/v0/mod.rs | 30 +- .../data_contract_update/mod.rs | 119 ++++++++ .../data_contract_update/state/v0/mod.rs | 31 +- .../rs-platform-version/src/version/v14.rs | 19 ++ .../src/errors/consensus/consensus_error.rs | 13 +- .../configuration/token_configuration.rs | 45 ++- .../tests/unit/TokenConfiguration.spec.ts | 177 ++++++----- 32 files changed, 1603 insertions(+), 109 deletions(-) create mode 100644 book/src/data-model/non-transferable-tokens.md create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/non_transferable_token_cost_tests.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_payment_must_burn_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_shielded_pool_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/token/token_not_transferable_error.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index ebdc24b6568..f0aa3d3b131 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -63,6 +63,7 @@ - [Identities](data-model/identities.md) - [Key Budgets and Expiry](data-model/key-limits.md) - [Token Shielded Pools](data-model/token-shielded-pools.md) +- [Non-Transferable Tokens](data-model/non-transferable-tokens.md) # Contract Keywords diff --git a/book/src/contract-keywords/token-cost.md b/book/src/contract-keywords/token-cost.md index 94db0485ec3..dfc70c9b571 100644 --- a/book/src/contract-keywords/token-cost.md +++ b/book/src/contract-keywords/token-cost.md @@ -9,7 +9,7 @@ | **Default** | Absent: no action costs tokens | | **Since** | protocol version 9. `gasFeesPaidBy` is accepted from 9 and acted on from 14; `optional` is 14 | | **On update** | Fixed: a cost may not be added, changed or removed on an existing document type (`DocumentTypeUpdateError`, 40212) | -| **Errors** | On a document transition: `RequiredTokenPaymentInfoNotSetError` (40115), `IdentityHasNotAgreedToPayRequiredTokenAmountError` (40116), `IdentityTryingToPayWithWrongTokenError` (40117), `IdentityTokenAccountFrozenError` (40702), `IdentityDoesNotHaveEnoughTokenBalanceError` (40700), `GasFeesPaidByNotAllowedError` (40129), `InconsistentGasFeesPaidByInBatchError` (40130), `GasSponsorInsufficientBalanceError` (40222). At registration: `InvalidTokenPositionError` (10451), `RedundantDocumentPaidForByTokenWithContractId` (10275), `TokenPaymentByBurningOnlyAllowedOnInternalTokenError` (10261), `DataContractNotFoundError` (40008), `InvalidTokenPositionStateError` (40009) | +| **Errors** | On a document transition: `RequiredTokenPaymentInfoNotSetError` (40115), `IdentityHasNotAgreedToPayRequiredTokenAmountError` (40116), `IdentityTryingToPayWithWrongTokenError` (40117), `IdentityTokenAccountFrozenError` (40702), `IdentityDoesNotHaveEnoughTokenBalanceError` (40700), `GasFeesPaidByNotAllowedError` (40129), `InconsistentGasFeesPaidByInBatchError` (40130), `GasSponsorInsufficientBalanceError` (40222). At registration: `InvalidTokenPositionError` (10451), `RedundantDocumentPaidForByTokenWithContractId` (10275), `TokenPaymentByBurningOnlyAllowedOnInternalTokenError` (10261), `NonTransferableTokenPaymentMustBurnError` (10280), `DataContractNotFoundError` (40008), `InvalidTokenPositionStateError` (40009), `TokenNotTransferableError` (40726) | The keys of each cost object: @@ -86,6 +86,8 @@ The signer pays: the creator for a create, the owner for a replace, delete, tran - `0`, the default, moves the tokens from the signer to the owner of the contract that holds the document type. When the contract owner performs the action themselves nothing moves, though their balance is still checked. - `1` burns the tokens from the signer's balance, lowering the token's supply. Only a token of the contract's own can be burned (`TokenPaymentByBurningOnlyAllowedOnInternalTokenError`, 10261, at registration). +A [non-transferable token](../data-model/non-transferable-tokens.md) (protocol version 14) can only be burned: paying the contract owner would move it to another identity, so a cost in the contract's own non-transferable token must set `effect: 1` (`NonTransferableTokenPaymentMustBurnError`, 10280, at registration). Another contract's non-transferable token cannot be charged at all, since it cannot be burned (`TokenNotTransferableError`, 40726, at registration). + ## Tokens of another contract: `contractId` A document type may charge a token another contract defines, for example a shared currency. `contractId` names that contract and `tokenPosition` the token in it. The effect must then be `0`: the tokens go to the owner of the contract holding the document type, not to the token's issuer. At registration the named contract must exist (`DataContractNotFoundError`, 40008) and have a token at that position (`InvalidTokenPositionStateError`, 40009), and it must not be the contract itself: leave `contractId` out for the contract's own tokens (`RedundantDocumentPaidForByTokenWithContractId`, 10275). @@ -119,7 +121,8 @@ Together with `gasFeesPaidBy` this gives a "free usage" pattern: an app hands ou - The meta-schema checks the shape (`JsonSchemaError`, 10101): only the six action keys; `tokenPosition` and `amount` required in each cost; values in the ranges above; no other key. Before protocol version 14 it also refuses `optional`. - Without `contractId`, `tokenPosition` must be a token of this contract (`InvalidTokenPositionError`, 10451). -- With `contractId`: not this contract's own id (10275), no burn (10261), and a contract that exists with a token at that position (40008, 40009). +- With `contractId`: not this contract's own id (10275), no burn (10261), and a contract that exists with a token at that position (40008, 40009). From protocol version 14 that token must be transferable (40726). +- Without `contractId`, from protocol version 14, a non-transferable token must be burned (10280). ## See also diff --git a/book/src/data-model/non-transferable-tokens.md b/book/src/data-model/non-transferable-tokens.md new file mode 100644 index 00000000000..f2a9d9ee2a9 --- /dev/null +++ b/book/src/data-model/non-transferable-tokens.md @@ -0,0 +1,63 @@ +# Non-Transferable Tokens + +From protocol version 14 a token can be non-transferable: once an identity holds it, no one can +move it to another identity, directly or through a contract. The token can still be minted, +claimed, bought, burned, frozen and paused as its rules allow, and it can still pay for the +documents of its own contract, by being burned. Reach for it when a token is a usage allowance, +points or a reputation score rather than a currency: an app hands it out, users spend it on the +app's own actions, and it never becomes something to sell or give away. + +## Configuration + +`TokenConfiguration` format version 1 carries `transferable: bool`, `true` when absent. A +version 0 configuration is always transferable, so only a format version 1 configuration can set +it to `false`, and protocol versions before 14 refuse that format at contract create and update. + +```json +{ + "$formatVersion": "1", + "baseSupply": 0, + "transferable": false, + ... +} +``` + +The flag is fixed when the token is created. No `TokenConfigUpdate` item changes it, and a +contract update cannot change an existing token's configuration. + +## What a non-transferable token still does + +- **Minting.** The minting rules decide who mints and whether they may choose the destination, + as for any token. This is how an issuer hands the token out; a base supply would sit with the + contract owner, who cannot send it on either. +- **Distributions and claims.** Perpetual, pre-programmed and once-per-identity distributions mint + to their recipients. +- **Direct purchase.** A purchase mints the tokens to the buyer and pays the price in credits. +- **Burning, freezing, unfreezing, destroying frozen funds and pausing**, under their own rules. +- **Paying for documents by burning.** A document type of the token's own contract may charge it + with `effect: 1` ([`tokenCost`](../contract-keywords/token-cost.md#effect-transfer-or-burn)). + +## What is refused + +| Operation | Refused with | Where | +|---|---|---| +| A `TokenTransfer` of the token | `TokenNotTransferableError` (40726) | Batch advanced structure validation, from the contract the action carries: the mempool refuses it, and a block charges the signer and bumps the nonce | +| A document type of the token's own contract charging it with `effect: 0` (pay the contract owner) | `NonTransferableTokenPaymentMustBurnError` (10280) | Contract create and update, when the document type is parsed | +| A document type of another contract charging it | `TokenNotTransferableError` (40726) | Contract create and update state validation, which already reads the token's contract to check the token exists. Such a cost always pays the contract owner, since another contract's token cannot be burned (10261) | +| `hasShieldedPool: true` | `NonTransferableTokenShieldedPoolError` (10279) | Contract create and update, before anything is charged | + +A shielded pool is refused because a note spent inside the pool can be unshielded to any +identity, so shielding and unshielding would move the token between holders. + +Every rule is checked when the contract is registered or when the transfer arrives, and the flag +cannot change afterwards, so a document payment never has to look the flag up: a document type +that could pay the owner in a non-transferable token is never registered. + +## Why not pause the token + +A paused token refuses transfers, but from protocol version 14 a paused token also refuses every +document payment, burns included (`TokenIsPausedError`, 40711). Before protocol version 14 a +paused token could still pay for documents, and since any contract may charge another contract's +token and pay it to its own owner, anyone could move a paused token by registering such a +contract. A non-transferable token closes that path at registration and keeps its document +payments working. diff --git a/book/src/data-model/token-shielded-pools.md b/book/src/data-model/token-shielded-pools.md index 1cbb46a3b1e..38515b67260 100644 --- a/book/src/data-model/token-shielded-pools.md +++ b/book/src/data-model/token-shielded-pools.md @@ -57,7 +57,8 @@ trees are created when a contract with the flag is inserted or updated. ## Configuration `TokenConfiguration` gains a format version 1. It adds `hasShieldedPool: bool`, the optional -`minimumPoolNotesForOutgoing` and the `minimumPoolNotesForOutgoingChangeRules` that govern it. +`minimumPoolNotesForOutgoing` and the `minimumPoolNotesForOutgoingChangeRules` that govern it, +and `transferable` ([Non-Transferable Tokens](non-transferable-tokens.md)). A version 0 configuration behaves as `hasShieldedPool: false`. `minimumPoolNotesForOutgoing` is how many notes a pool must hold before tokens may leave it. @@ -87,6 +88,9 @@ balances, a token with `hasShieldedPool` must disable them permanently: `freezeR `unfreezeRules` and `destroyFrozenFundsRules` must each authorize no one to take the action and have no admin action takers, so no later configuration update can switch them on. Contract create and update reject anything else with `TokenShieldedPoolIncompatibleRulesError` (10278). +A pooled token must also be transferable: a note spent inside the pool can be unshielded to any +identity, so contract create and update reject `transferable: false` with a pool +(`NonTransferableTokenShieldedPoolError`, 10279). Pausing still works: every pool operation, inflows included (shield, mint, claim and purchase into the pool) and outflows (unshield, shielded transfer, burn from the pool), is rejected while the token is paused. A pooled token can never freeze an account, so the pool validators read and bill no diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/mod.rs index f8cd03fcdd7..560b41e8ccd 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/mod.rs @@ -255,13 +255,20 @@ impl TokenConfigurationV1Getters for TokenConfiguration { TokenConfiguration::V1(v1) => v1.minimum_pool_notes_for_outgoing(), } } + + fn is_transferable(&self) -> bool { + match self { + TokenConfiguration::V0(_) => true, + TokenConfiguration::V1(v1) => v1.is_transferable(), + } + } } impl TokenConfigurationV1Setters for TokenConfiguration { /// The representation stays canonical: enabling the pool on a V0 configuration upgrades - /// it in place to V1, and disabling it on a V1 configuration downgrades it back to V0, so - /// a configuration without a pool always serializes as format version 0 and is accepted - /// by every protocol version. + /// it in place to V1, and disabling it on a transferable V1 configuration downgrades it + /// back to V0, so a configuration using no V1 feature always serializes as format version 0 + /// and is accepted by every protocol version. fn set_has_shielded_pool(&mut self, has_shielded_pool: bool) { match self { TokenConfiguration::V0(v0) => { @@ -272,16 +279,46 @@ impl TokenConfigurationV1Setters for TokenConfiguration { } } TokenConfiguration::V1(v1) => { - if has_shielded_pool { - v1.set_has_shielded_pool(true); - } else { - let base = std::mem::replace( - &mut v1.base, - TokenConfigurationV0::default_most_restrictive(), - ); - *self = TokenConfiguration::V0(base); + v1.set_has_shielded_pool(has_shielded_pool); + self.downgrade_to_v0_if_unused(); + } + } + } + + /// Canonical like `set_has_shielded_pool`: making a V0 configuration non-transferable + /// upgrades it to V1, and making a V1 configuration without a pool transferable again + /// downgrades it to V0. + fn set_transferable(&mut self, transferable: bool) { + match self { + TokenConfiguration::V0(v0) => { + if !transferable { + let base = + std::mem::replace(v0, TokenConfigurationV0::default_most_restrictive()); + let mut v1 = TokenConfigurationV1::from_v0(base, false); + v1.set_transferable(false); + *self = TokenConfiguration::V1(v1); } } + TokenConfiguration::V1(v1) => { + v1.set_transferable(transferable); + self.downgrade_to_v0_if_unused(); + } + } + } +} + +impl TokenConfiguration { + /// Replaces a V1 configuration that has no pool and is transferable by its V0 base. The + /// pool's threshold and its rules go with it: they only apply to a pool. + fn downgrade_to_v0_if_unused(&mut self) { + if let TokenConfiguration::V1(v1) = self { + if !v1.has_shielded_pool && v1.transferable { + let base = std::mem::replace( + &mut v1.base, + TokenConfigurationV0::default_most_restrictive(), + ); + *self = TokenConfiguration::V0(base); + } } } } diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/v1/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/v1/mod.rs index c31da7f2b65..772e7ef4dd3 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/v1/mod.rs @@ -1,7 +1,8 @@ /// Getters introduced by `TokenConfigurationV1`. /// -/// Implemented for every configuration version: a V0 configuration answers `false` and 0, -/// since neither the shielded pool opt-in nor its threshold existed before V1. +/// Implemented for every configuration version: a V0 configuration answers `false`, 0 and +/// transferable, since neither the shielded pool opt-in, its threshold nor non-transferable +/// tokens existed before V1. pub trait TokenConfigurationV1Getters { /// Whether the token has its own shielded pool. fn has_shielded_pool(&self) -> bool; @@ -9,10 +10,16 @@ pub trait TokenConfigurationV1Getters { /// The fewest notes the token's shielded pool must hold before tokens may leave it for a /// visible destination; 0 when the configuration sets none. fn minimum_pool_notes_for_outgoing(&self) -> u64; + + /// Whether holders may move the token to another identity. + fn is_transferable(&self) -> bool; } /// Setters introduced by `TokenConfigurationV1`. pub trait TokenConfigurationV1Setters { /// Sets whether the token has its own shielded pool. fn set_has_shielded_pool(&mut self, has_shielded_pool: bool); + + /// Sets whether holders may move the token to another identity. + fn set_transferable(&mut self, transferable: bool); } diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs index 450764693b2..fecec51256b 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs @@ -1,4 +1,6 @@ -use crate::consensus::basic::data_contract::TokenShieldedPoolIncompatibleRulesError; +use crate::consensus::basic::data_contract::{ + NonTransferableTokenShieldedPoolError, TokenShieldedPoolIncompatibleRulesError, +}; use crate::consensus::basic::unsupported_version_error::UnsupportedVersionError; use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters; @@ -91,8 +93,10 @@ impl TokenConfiguration { /// let an issuer advertise controls that only cover transparent balances, a token with /// `hasShieldedPool` must permanently disable `freezeRules`, `unfreezeRules` and /// `destroyFrozenFundsRules`: no one may take the action and no one may administer the - /// rule, so no configuration update can ever switch them on. Checked on contract create - /// and update; the flag itself is immutable. + /// rule, so no configuration update can ever switch them on. The token must also be + /// transferable: a spend inside the pool can pay any identity, so a non-transferable token + /// with a pool would change hands through shield and unshield. Checked on contract create + /// and update; both flags are immutable. pub fn validate_shielded_pool_rules( &self, token_contract_position: TokenContractPosition, @@ -100,6 +104,11 @@ impl TokenConfiguration { if !self.has_shielded_pool() { return SimpleConsensusValidationResult::new(); } + if !self.is_transferable() { + return SimpleConsensusValidationResult::new_with_error( + NonTransferableTokenShieldedPoolError::new(token_contract_position).into(), + ); + } let rules: [(&ChangeControlRules, &str); 3] = [ (self.freeze_rules(), "freezeRules"), (self.unfreeze_rules(), "unfreezeRules"), @@ -878,3 +887,152 @@ mod unknown_configuration_key_tests { assert!(clean.has_shielded_pool()); } } + +#[cfg(test)] +mod non_transferable_tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::codes::ErrorWithCode; + use crate::consensus::ConsensusError; + use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; + + fn transferable() -> TokenConfiguration { + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()) + } + + fn non_transferable() -> TokenConfiguration { + let mut configuration = transferable(); + configuration.set_transferable(false); + configuration + } + + #[test] + fn should_read_every_format_version_0_configuration_as_transferable() { + assert!(transferable().is_transferable()); + } + + #[test] + fn should_make_a_non_transferable_token_a_format_version_1_configuration_without_a_pool() { + let configuration = non_transferable(); + assert_eq!(configuration.format_version(), 1); + assert!(!configuration.is_transferable()); + assert!(!configuration.has_shielded_pool()); + } + + #[test] + fn should_return_to_format_version_0_when_made_transferable_again() { + let mut configuration = non_transferable(); + configuration.set_transferable(true); + assert_eq!(configuration, transferable()); + } + + /// Disabling a pool used to downgrade every V1 configuration to V0, which would now drop + /// the non-transferable flag along with the pool. + #[test] + fn should_keep_a_non_transferable_token_non_transferable_when_its_pool_is_removed() { + let mut configuration = non_transferable(); + configuration.set_has_shielded_pool(true); + configuration.set_has_shielded_pool(false); + assert_eq!(configuration, non_transferable()); + } + + #[test] + fn should_refuse_a_shielded_pool_on_a_non_transferable_token_with_error_10279() { + let mut pooled = transferable(); + pooled.set_has_shielded_pool(true); + assert!(pooled.validate_shielded_pool_rules(2).is_valid()); + + pooled.set_transferable(false); + let result = pooled.validate_shielded_pool_rules(2); + match result.errors.as_slice() { + [error @ ConsensusError::BasicError( + BasicError::NonTransferableTokenShieldedPoolError(inner), + )] => { + assert_eq!(inner.token_contract_position(), 2); + assert_eq!(error.code(), 10279); + } + other => panic!("expected NonTransferableTokenShieldedPoolError, got {other:?}"), + } + } + + #[test] + fn should_accept_a_non_transferable_token_without_a_pool() { + let configurations = BTreeMap::from([(0, non_transferable())]); + assert!( + validate_token_configurations(&configurations, PlatformVersion::latest()).is_valid() + ); + } + + #[test] + fn should_round_trip_a_non_transferable_configuration_through_bincode() { + let configuration = non_transferable(); + let bytes = + bincode::encode_to_vec(&configuration, bincode::config::standard()).expect("encode"); + let (decoded, read): (TokenConfiguration, usize) = + bincode::decode_from_slice(&bytes, bincode::config::standard()).expect("decode"); + assert_eq!(read, bytes.len()); + assert_eq!(decoded, configuration); + } +} + +#[cfg(all( + test, + feature = "json-conversion", + feature = "value-conversion", + feature = "serde-conversion" +))] +mod non_transferable_json_tests { + use super::*; + use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; + use crate::serialization::{JsonConvertible, ValueConvertible}; + + fn non_transferable() -> TokenConfiguration { + let mut configuration = + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()); + configuration.set_transferable(false); + configuration + } + + #[test] + fn should_write_transferable_false_and_read_it_back_on_both_wires() { + let configuration = non_transferable(); + + let json = configuration.to_json().expect("to_json"); + assert_eq!( + json.get("$formatVersion").and_then(|v| v.as_str()), + Some("1") + ); + assert_eq!( + json.get("transferable"), + Some(&serde_json::Value::Bool(false)) + ); + assert_eq!( + TokenConfiguration::from_json(json).expect("from_json"), + configuration + ); + + let value = configuration.to_object().expect("to_object"); + assert_eq!( + TokenConfiguration::from_object(value).expect("from_object"), + configuration + ); + } + + /// A format 1 configuration written without the key, such as a pooled token from before + /// the flag existed, reads as transferable. + #[test] + fn should_read_a_format_version_1_configuration_without_the_key_as_transferable() { + let mut pooled = TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()); + pooled.set_has_shielded_pool(true); + let mut json = pooled.to_json().expect("to_json"); + assert!(json + .as_object_mut() + .expect("configuration object") + .remove("transferable") + .is_some()); + + let decoded = TokenConfiguration::from_json(json).expect("from_json"); + assert!(decoded.is_transferable()); + assert_eq!(decoded, pooled); + } +} diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/accessors/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/accessors/mod.rs index b87f40b9e85..fd42a067e96 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/accessors/mod.rs @@ -210,10 +210,18 @@ impl TokenConfigurationV1Getters for TokenConfigurationV1 { fn minimum_pool_notes_for_outgoing(&self) -> u64 { self.minimum_pool_notes_for_outgoing.unwrap_or(0) } + + fn is_transferable(&self) -> bool { + self.transferable + } } impl TokenConfigurationV1Setters for TokenConfigurationV1 { fn set_has_shielded_pool(&mut self, has_shielded_pool: bool) { self.has_shielded_pool = has_shielded_pool; } + + fn set_transferable(&mut self, transferable: bool) { + self.transferable = transferable; + } } diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/mod.rs index 6e84cc0b16a..b53c665f44e 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/mod.rs @@ -11,18 +11,21 @@ use serde::{Deserialize, Serialize}; use std::fmt; /// Version 1 of the token configuration: everything `TokenConfigurationV0` carries, plus the -/// per-token shielded pool opt-in and the pool's outgoing notes threshold. +/// per-token shielded pool opt-in, the pool's outgoing notes threshold and whether holders may +/// move the token to anyone else. /// /// The V0 fields are nested as `base` and flattened on the JSON / Value wire, so a V1 /// configuration reads exactly like a V0 one with `$formatVersion: "1"` and the extra -/// `hasShieldedPool`, `minimumPoolNotesForOutgoing` and `minimumPoolNotesForOutgoingChangeRules` -/// keys. On the bincode wire it is the V0 bytes followed by the flag, the threshold and its -/// rules, under the enum's variant index 1, so stored V0 configurations decode unchanged. +/// `hasShieldedPool`, `minimumPoolNotesForOutgoing`, `minimumPoolNotesForOutgoingChangeRules` +/// and `transferable` keys. On the bincode wire it is the V0 bytes followed by the flag, the +/// threshold, its rules and the transferable flag, under the enum's variant index 1, so stored +/// V0 configurations decode unchanged. /// /// `has_shielded_pool` is decided at token creation and is immutable afterwards: the pool /// subtree (an Orchard note commitment tree, nullifier set, anchors and a balance) is created /// together with the token's other trees, and a pool holding notes can never be removed. -/// Enabling a pool on an existing token is not supported by this version. +/// Enabling a pool on an existing token is not supported by this version. `transferable` is +/// fixed at creation too: no `TokenConfigUpdate` item changes it. #[cfg_attr(feature = "json-conversion", json_safe_fields)] #[derive(Serialize, Deserialize, Decode, Encode, Debug, Clone, PartialEq, Eq, DecodeUntrusted)] // An unknown key is refused rather than dropped: what a token can do is fixed when it is @@ -58,6 +61,18 @@ pub struct TokenConfigurationV1 { /// when absent. #[serde(default = "default_change_control_rules")] pub minimum_pool_notes_for_outgoing_change_rules: ChangeControlRules, + /// Whether holders may move this token to another identity. When `false` the token still + /// mints, is claimed, bought from the issuer, burned, frozen and destroyed as configured, but + /// a `TokenTransfer` is refused, a document type may charge it only by burning it (so only + /// its own contract's document types can use it, external burns being refused), and it + /// cannot have a shielded pool, whose notes change hands without an identity. `true` when + /// absent, as for every V0 configuration. + #[serde(default = "default_transferable")] + pub transferable: bool, +} + +fn default_transferable() -> bool { + true } impl TokenConfigurationV1 { @@ -68,6 +83,7 @@ impl TokenConfigurationV1 { has_shielded_pool, minimum_pool_notes_for_outgoing: None, minimum_pool_notes_for_outgoing_change_rules: default_change_control_rules(), + transferable: true, } } } @@ -76,11 +92,12 @@ impl fmt::Display for TokenConfigurationV1 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "TokenConfigurationV1 {{\n base: {},\n has_shielded_pool: {},\n minimum_pool_notes_for_outgoing: {:?},\n minimum_pool_notes_for_outgoing_change_rules: {:?}\n}}", + "TokenConfigurationV1 {{\n base: {},\n has_shielded_pool: {},\n minimum_pool_notes_for_outgoing: {:?},\n minimum_pool_notes_for_outgoing_change_rules: {:?},\n transferable: {}\n}}", self.base, self.has_shielded_pool, self.minimum_pool_notes_for_outgoing, - self.minimum_pool_notes_for_outgoing_change_rules + self.minimum_pool_notes_for_outgoing_change_rules, + self.transferable ) } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index a8f7c34c78f..e4ee3532e20 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -83,7 +83,8 @@ use super::{insert_values, insert_values_nested}; #[cfg(feature = "validation")] use crate::consensus::basic::data_contract::{ ContestedUniqueIndexOnMutableDocumentTypeError, ContestedUniqueIndexWithUniqueIndexError, - InvalidDocumentTypeNameError, RedundantDocumentPaidForByTokenWithContractId, + InvalidDocumentTypeNameError, NonTransferableTokenPaymentMustBurnError, + RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, }; #[cfg(feature = "validation")] @@ -102,6 +103,8 @@ use crate::consensus::basic::UnsupportedFeatureError; #[cfg(feature = "validation")] use crate::consensus::ConsensusError; #[cfg(feature = "validation")] +use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters; +#[cfg(feature = "validation")] use crate::data_contract::document_type::schema::validate_max_depth; #[cfg(feature = "validation")] use crate::data_contract::document_type::validator::StatelessJsonSchemaLazyValidator; @@ -1685,6 +1688,31 @@ fn parse_token_costs( )); } + // A non-transferable token pays only by burning: paying the contract + // owner would move it to another identity. Only a format 1 token + // configuration can be non-transferable, and the pre-activation gate + // refuses that format before protocol version 14, so no earlier version + // reaches this refusal. + if target_contract_id.is_none() + && effect == DocumentActionTokenEffect::TransferTokenToContractOwner + && ctx + .token_configurations + .get(&token_contract_position) + .is_some_and(|configuration| !configuration.is_transferable()) + { + return Err(ProtocolError::ConsensusError( + ConsensusError::BasicError( + BasicError::NonTransferableTokenPaymentMustBurnError( + NonTransferableTokenPaymentMustBurnError::new( + token_contract_position, + key.to_string(), + ), + ), + ) + .into(), + )); + } + // If contractId is present and user tries to burn, bail out: if let Some(target_contract_id) = target_contract_id { if target_contract_id == ctx.data_contract_id { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 6705a27eb47..bd562eca8f3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -1896,6 +1896,8 @@ mod meta_schema_v0_stray_keyword_tests; mod moderator_abilities_tests; #[cfg(all(test, feature = "validation"))] mod name_rules_tests; +#[cfg(all(test, feature = "validation"))] +mod non_transferable_token_cost_tests; #[cfg(test)] mod only_when_consumed_tests; #[cfg(all(test, feature = "validation"))] diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/non_transferable_token_cost_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/non_transferable_token_cost_tests.rs new file mode 100644 index 00000000000..d2f84d7f3af --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/non_transferable_token_cost_tests.rs @@ -0,0 +1,98 @@ +//! A document type paying its contract's own non-transferable token (protocol version 14): it +//! may burn the token, but paying the contract owner would move the token to another identity. +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::codes::ErrorWithCode; +use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; +use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; +use crate::data_contract::document_type::accessors::DocumentTypeV1Getters; +use crate::tokens::token_amount_on_contract_token::DocumentActionTokenEffect; +use platform_value::platform_value; + +fn token(transferable: bool) -> BTreeMap { + let mut configuration = + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()); + configuration.set_transferable(transferable); + BTreeMap::from([(0, configuration)]) +} + +/// A `post` whose create costs one of the contract's token at position 0, with `effect` when +/// given (the default pays the contract owner). +fn schema(effect: Option) -> Value { + let mut cost = platform_value!({ "tokenPosition": 0_u64, "amount": 10_u64 }); + if let Some(effect) = effect { + cost.as_map_mut() + .expect("cost map") + .push((Value::Text("effect".to_string()), Value::U64(effect))); + } + platform_value!({ + "type": "object", + "properties": { + "text": {"type": "string", "position": 0, "maxLength": 40_u32}, + }, + "tokenCost": { "create": cost }, + "additionalProperties": false, + }) +} + +fn parse( + schema: Value, + token_configurations: &BTreeMap, +) -> Result { + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "post", + schema, + None, + token_configurations, + &config, + true, + &mut vec![], + platform_version, + ) +} + +#[test] +fn should_refuse_paying_the_contract_owner_its_own_non_transferable_token_with_error_10280() { + for effect in [None, Some(0)] { + let error = parse(schema(effect), &token(false)) + .expect_err("paying the owner a non-transferable token is refused"); + let ProtocolError::ConsensusError(error) = error else { + panic!("expected a consensus error, got {error:?}"); + }; + let ConsensusError::BasicError(BasicError::NonTransferableTokenPaymentMustBurnError(inner)) = + error.as_ref() + else { + panic!("expected NonTransferableTokenPaymentMustBurnError, got {error:?}"); + }; + assert_eq!(inner.token_contract_position(), 0); + assert_eq!(inner.action(), "create"); + assert_eq!(error.code(), 10280); + } +} + +#[test] +fn should_accept_burning_its_own_non_transferable_token() { + let document_type = parse(schema(Some(1)), &token(false)).expect("a burn cost is accepted"); + let cost = document_type + .document_creation_token_cost() + .expect("the create cost is kept"); + assert_eq!(cost.effect, DocumentActionTokenEffect::BurnToken); +} + +#[test] +fn should_still_let_a_transferable_token_pay_the_contract_owner() { + let document_type = parse(schema(None), &token(true)).expect("a transferable token pays"); + let cost = document_type + .document_creation_token_cost() + .expect("the create cost is kept"); + assert_eq!( + cost.effect, + DocumentActionTokenEffect::TransferTokenToContractOwner + ); +} diff --git a/packages/rs-dpp/src/data_contract/methods/validate_update/v0/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_update/v0/mod.rs index 03628b5341d..95eca1da3cc 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_update/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_update/v0/mod.rs @@ -574,10 +574,11 @@ mod tests { has_shielded_pool: _, minimum_pool_notes_for_outgoing: _, minimum_pool_notes_for_outgoing_change_rules: _, + transferable: _, } = original; type Change = fn(&mut TokenConfigurationV1); - let changes: [(&str, Change); 3] = [ + let changes: [(&str, Change); 4] = [ ("has_shielded_pool", |v1| v1.has_shielded_pool = false), ("minimum_pool_notes_for_outgoing", |v1| { v1.minimum_pool_notes_for_outgoing = Some(1) @@ -588,6 +589,7 @@ mod tests { AuthorizedActionTakers::ContractOwner, ) }), + ("transferable", |v1| v1.transferable = false), ]; for (field, change) in changes { let mut old_data_contract = diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index d4800948464..c08c83d70c1 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -37,7 +37,8 @@ use crate::consensus::basic::data_contract::{ InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, - NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, + NonContiguousContractTokenPositionsError, NonTransferableTokenPaymentMustBurnError, + NonTransferableTokenShieldedPoolError, PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, SystemPropertyIndexAlreadyPresentError, TokenShieldedPoolIncompatibleRulesError, UndefinedIndexPropertyError, UniqueIndicesLimitReachedError, UnknownDocumentCreationRestrictionModeError, @@ -854,6 +855,15 @@ pub enum BasicError { // A token opting into a shielded pool keeps no freeze rules (protocol version 14). #[error(transparent)] TokenShieldedPoolIncompatibleRulesError(TokenShieldedPoolIncompatibleRulesError), + + // A non-transferable token cannot have a shielded pool (protocol version 14). + #[error(transparent)] + NonTransferableTokenShieldedPoolError(NonTransferableTokenShieldedPoolError), + + // A document type pays its contract's own non-transferable token to the contract owner + // instead of burning it (protocol version 14). + #[error(transparent)] + NonTransferableTokenPaymentMustBurnError(NonTransferableTokenPaymentMustBurnError), } impl From for ConsensusError { @@ -1040,14 +1050,28 @@ mod tests { )), 202 ); - // A token opting into a shielded pool keeps no freeze rules (protocol version 14): the - // tail of the enum. + // A token opting into a shielded pool keeps no freeze rules (protocol version 14). assert_eq!( discriminant_of(BasicError::TokenShieldedPoolIncompatibleRulesError( TokenShieldedPoolIncompatibleRulesError::new(0, "freezeRules".to_string()) )), 203 ); + // A non-transferable token with a shielded pool (protocol version 14). + assert_eq!( + discriminant_of(BasicError::NonTransferableTokenShieldedPoolError( + NonTransferableTokenShieldedPoolError::new(0) + )), + 204 + ); + // A document type paying its own non-transferable token to the contract owner + // (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(BasicError::NonTransferableTokenPaymentMustBurnError( + NonTransferableTokenPaymentMustBurnError::new(0, "create".to_string()) + )), + 205 + ); } /// The variants that shipped in 4.1 keep the discriminants they were released with, so an diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs index 24fe9e6c414..290ee0f8c2c 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs @@ -46,6 +46,8 @@ mod main_group_is_not_defined; mod new_tokens_destination_identity_option_required_error; mod non_contiguous_contract_group_positions_error; mod non_contiguous_contract_token_positions_error; +mod non_transferable_token_payment_must_burn_error; +mod non_transferable_token_shielded_pool_error; mod pre_programmed_distribution_amount_over_limit_error; mod redundant_document_paid_for_by_token_with_contract_id; mod system_property_index_already_present_error; @@ -114,6 +116,8 @@ pub use main_group_is_not_defined::*; pub use new_tokens_destination_identity_option_required_error::*; pub use non_contiguous_contract_group_positions_error::*; pub use non_contiguous_contract_token_positions_error::*; +pub use non_transferable_token_payment_must_burn_error::*; +pub use non_transferable_token_shielded_pool_error::*; pub use pre_programmed_distribution_amount_over_limit_error::*; pub use redundant_document_paid_for_by_token_with_contract_id::*; pub use token_decimals_over_limit_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_payment_must_burn_error.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_payment_must_burn_error.rs new file mode 100644 index 00000000000..1e6d0437050 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_payment_must_burn_error.rs @@ -0,0 +1,64 @@ +use crate::consensus::basic::BasicError; +use crate::data_contract::TokenContractPosition; +use crate::errors::ProtocolError; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +use crate::consensus::ConsensusError; + +use bincode::{Decode, DecodeUntrusted, Encode}; + +/// A document type charges its contract's own non-transferable token without burning it. Paying +/// the contract owner would move the token to another identity, so the cost must use the burn +/// effect. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Token at position {token_contract_position} is not transferable, so the {action} token cost must burn it instead of paying the contract owner" +)] +#[platform_serialize(unversioned)] +pub struct NonTransferableTokenPaymentMustBurnError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_contract_position: TokenContractPosition, + action: String, +} + +impl NonTransferableTokenPaymentMustBurnError { + pub fn new(token_contract_position: TokenContractPosition, action: String) -> Self { + Self { + token_contract_position, + action, + } + } + + pub fn token_contract_position(&self) -> TokenContractPosition { + self.token_contract_position + } + + pub fn action(&self) -> &str { + &self.action + } +} + +impl From for ConsensusError { + fn from(err: NonTransferableTokenPaymentMustBurnError) -> Self { + Self::BasicError(BasicError::NonTransferableTokenPaymentMustBurnError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_shielded_pool_error.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_shielded_pool_error.rs new file mode 100644 index 00000000000..2e8941aff26 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/non_transferable_token_shielded_pool_error.rs @@ -0,0 +1,57 @@ +use crate::consensus::basic::BasicError; +use crate::data_contract::TokenContractPosition; +use crate::errors::ProtocolError; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +use crate::consensus::ConsensusError; + +use bincode::{Decode, DecodeUntrusted, Encode}; + +/// A non-transferable token cannot have a shielded pool: a note spent in the pool can pay out to +/// any identity, so shielding and unshielding would move the token between holders. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Token at position {token_contract_position} is not transferable, so it cannot have a shielded pool: shielded notes can be unshielded to any identity" +)] +#[platform_serialize(unversioned)] +pub struct NonTransferableTokenShieldedPoolError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_contract_position: TokenContractPosition, +} + +impl NonTransferableTokenShieldedPoolError { + pub fn new(token_contract_position: TokenContractPosition) -> Self { + Self { + token_contract_position, + } + } + + pub fn token_contract_position(&self) -> TokenContractPosition { + self.token_contract_position + } +} + +impl From for ConsensusError { + fn from(err: NonTransferableTokenShieldedPoolError) -> Self { + Self::BasicError(BasicError::NonTransferableTokenShieldedPoolError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 68aec019318..d07b26f1137 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -123,6 +123,8 @@ impl ErrorWithCode for BasicError { Self::DataContractInvalidRequiredFieldsUpdateError { .. } => 10276, Self::PreProgrammedDistributionAmountOverLimitError(_) => 10277, Self::TokenShieldedPoolIncompatibleRulesError(_) => 10278, + Self::NonTransferableTokenShieldedPoolError(_) => 10279, + Self::NonTransferableTokenPaymentMustBurnError(_) => 10280, // Group Errors: 10350-10399 Self::GroupPositionDoesNotExistError(_) => 10350, @@ -458,6 +460,7 @@ impl ErrorWithCode for StateError { Self::TokenShieldedPoolNotEnabledError(_) => 40723, Self::TokenShieldedPaymentAmountMismatchError(_) => 40724, Self::TokenShieldedPaymentNotRequiredError(_) => 40725, + Self::TokenNotTransferableError(_) => 40726, // Group errors: 40800-40899 Self::IdentityNotMemberOfGroupError(_) => 40800, diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index fe195d823a9..884b4e2c69e 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -112,7 +112,7 @@ use crate::consensus::state::identity::missing_transfer_key_error::MissingTransf use crate::consensus::state::identity::no_transfer_key_for_core_withdrawal_available_error::NoTransferKeyForCoreWithdrawalAvailableError; use crate::consensus::state::prefunded_specialized_balances::prefunded_specialized_balance_insufficient_error::PrefundedSpecializedBalanceInsufficientError; use crate::consensus::state::prefunded_specialized_balances::prefunded_specialized_balance_not_found_error::PrefundedSpecializedBalanceNotFoundError; -use crate::consensus::state::token::{IdentityDoesNotHaveEnoughTokenBalanceError, IdentityTokenAccountFrozenError, IdentityTokenAccountNotFrozenError, InvalidGroupPositionError, NewAuthorizedActionTakerGroupDoesNotExistError, NewAuthorizedActionTakerIdentityDoesNotExistError, NewAuthorizedActionTakerMainGroupNotSetError, NewTokensDestinationIdentityDoesNotExistError, TokenMintPastMaxSupplyError, TokenSettingMaxSupplyToLessThanCurrentSupplyError, UnauthorizedTokenActionError, IdentityTokenAccountAlreadyFrozenError, TokenAlreadyPausedError, TokenIsPausedError, TokenNotPausedError, InvalidTokenClaimPropertyMismatch, InvalidTokenClaimNoCurrentRewards, InvalidTokenClaimWrongClaimant, PreProgrammedDistributionTimestampInPastError, TokenTransferRecipientIdentityNotExistError, IdentityHasNotAgreedToPayRequiredTokenAmountError, RequiredTokenPaymentInfoNotSetError, IdentityTryingToPayWithWrongTokenError, TokenDirectPurchaseUserPriceTooLow, TokenAmountUnderMinimumSaleAmount, TokenNotForDirectSale, InvalidTokenPositionStateError, TokenOncePerIdentityDistributionAlreadyClaimedError, TokenShieldedPoolNotEnabledError, TokenShieldedPaymentAmountMismatchError, TokenShieldedPaymentNotRequiredError}; +use crate::consensus::state::token::{IdentityDoesNotHaveEnoughTokenBalanceError, IdentityTokenAccountFrozenError, IdentityTokenAccountNotFrozenError, InvalidGroupPositionError, NewAuthorizedActionTakerGroupDoesNotExistError, NewAuthorizedActionTakerIdentityDoesNotExistError, NewAuthorizedActionTakerMainGroupNotSetError, NewTokensDestinationIdentityDoesNotExistError, TokenMintPastMaxSupplyError, TokenSettingMaxSupplyToLessThanCurrentSupplyError, UnauthorizedTokenActionError, IdentityTokenAccountAlreadyFrozenError, TokenAlreadyPausedError, TokenIsPausedError, TokenNotPausedError, InvalidTokenClaimPropertyMismatch, InvalidTokenClaimNoCurrentRewards, InvalidTokenClaimWrongClaimant, PreProgrammedDistributionTimestampInPastError, TokenTransferRecipientIdentityNotExistError, IdentityHasNotAgreedToPayRequiredTokenAmountError, RequiredTokenPaymentInfoNotSetError, IdentityTryingToPayWithWrongTokenError, TokenDirectPurchaseUserPriceTooLow, TokenAmountUnderMinimumSaleAmount, TokenNotForDirectSale, InvalidTokenPositionStateError, TokenOncePerIdentityDistributionAlreadyClaimedError, TokenShieldedPoolNotEnabledError, TokenShieldedPaymentAmountMismatchError, TokenShieldedPaymentNotRequiredError, TokenNotTransferableError}; use crate::consensus::state::voting::masternode_incorrect_voter_identity_id_error::MasternodeIncorrectVoterIdentityIdError; use crate::consensus::state::voting::masternode_incorrect_voting_address_error::MasternodeIncorrectVotingAddressError; use crate::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; @@ -728,6 +728,11 @@ pub enum StateError { // (protocol version 14). #[error(transparent)] DocumentDeleteConstraintViolatedError(DocumentDeleteConstraintViolatedError), + + // A transfer of a token whose configuration sets `transferable: false`, or a contract whose + // document type would pay such a token to its owner (protocol version 14). + #[error(transparent)] + TokenNotTransferableError(TokenNotTransferableError), } impl From for ConsensusError { @@ -1614,5 +1619,12 @@ mod tests { )), 172 ); + // A transfer of a non-transferable token (protocol version 14). + assert_eq!( + discriminant_of(StateError::TokenNotTransferableError( + TokenNotTransferableError::new(identity_id, "transfer".to_string()) + )), + 173 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs index c9a33400965..71a94a6d7fb 100644 --- a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs @@ -24,6 +24,7 @@ mod token_is_paused_error; mod token_mint_past_max_supply_error; mod token_not_for_direct_sale; mod token_not_paused_error; +mod token_not_transferable_error; mod token_once_per_identity_distribution_already_claimed_error; mod token_setting_max_supply_to_less_than_current_supply_error; mod token_shielded_payment_amount_mismatch_error; @@ -58,6 +59,7 @@ pub use token_is_paused_error::*; pub use token_mint_past_max_supply_error::*; pub use token_not_for_direct_sale::*; pub use token_not_paused_error::*; +pub use token_not_transferable_error::*; pub use token_once_per_identity_distribution_already_claimed_error::*; pub use token_setting_max_supply_to_less_than_current_supply_error::*; pub use token_shielded_payment_amount_mismatch_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_not_transferable_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_not_transferable_error.rs new file mode 100644 index 00000000000..c831b32fe7c --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_not_transferable_error.rs @@ -0,0 +1,57 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// The token's configuration sets `transferable: false`, and the refused operation would move +/// it to another identity: a `TokenTransfer`, or a contract whose document type pays the token +/// to its own owner. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token {token_id} is not transferable: {action} refused")] +#[platform_serialize(unversioned)] +pub struct TokenNotTransferableError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_id: Identifier, + action: String, +} + +impl TokenNotTransferableError { + pub fn new(token_id: Identifier, action: String) -> Self { + Self { token_id, action } + } + + pub fn token_id(&self) -> &Identifier { + &self.token_id + } + + pub fn action(&self) -> &str { + &self.action + } +} + +impl From for ConsensusError { + fn from(err: TokenNotTransferableError) -> Self { + Self::StateError(StateError::TokenNotTransferableError(err)) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs index 8ab720072a5..596a0e3a3a3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -40,6 +40,10 @@ use drive::state_transition_action::batch::batched_transition::document_transiti use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use dpp::consensus::state::token::TokenNotTransferableError; +use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters; use drive::state_transition_action::StateTransitionAction; use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; use crate::error::execution::ExecutionError; @@ -446,7 +450,8 @@ impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { } }, BatchedTransitionAction::TokenAction(token_transition_action) => { - // token actions only need to do advanced structure validation on the base action + // token actions do advanced structure validation on the base action, and a + // transfer is refused for a token that is not transferable let result = token_transition_action .base() .validate_structure(platform_version)?; @@ -460,6 +465,30 @@ impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { result.errors, )); } + // The token's configuration comes with the contract the action already + // carries, so the check needs no read and the mempool refuses the transfer + // as well. The base validation above established the token exists. + if matches!( + token_transition_action, + TokenTransitionAction::TransferAction(_) + ) && !token_transition_action + .base() + .token_configuration()? + .is_transferable() + { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition_action(token_transition_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![TokenNotTransferableError::new( + token_transition_action.base().token_id(), + "transfer".to_string(), + ) + .into()], + )); + } } BatchedTransitionAction::BumpIdentityDataContractNonce(_) => { return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs index 7d54d5655d2..aa063c35a84 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs @@ -8,6 +8,7 @@ mod emergency_action; mod freeze; mod mint; mod mint_many; +mod non_transferable; mod shielded; mod transfer; mod unfreeze; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs new file mode 100644 index 00000000000..9db3fd14ac6 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs @@ -0,0 +1,278 @@ +//! A token whose configuration sets `transferable: false` (protocol version 14): no +//! `TokenTransfer` moves it, and a document of its own contract may still charge it by burning. +use super::*; +use crate::execution::check_tx::CheckTxLevel; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::codes::ErrorWithCode; +use dpp::data_contract::accessors::v1::DataContractV1Setters; +use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; +use dpp::data_contract::document_type::random_document::{ + DocumentFieldFillSize, DocumentFieldFillType, +}; +use dpp::data_contract::DataContract; +use dpp::document::DocumentV0Setters; +use dpp::tokens::calculate_token_id; +use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; +use dpp::tokens::token_payment_info::v0::TokenPaymentInfoV0; +use dpp::tokens::token_payment_info::TokenPaymentInfo; +use drive::util::test_helpers::setup_contract; + +fn make_non_transferable(token_configuration: &mut TokenConfiguration) { + token_configuration.set_transferable(false); +} + +#[tokio::test] +async fn should_refuse_a_transfer_of_a_non_transferable_token_in_the_mempool_and_in_a_block() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (recipient, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(make_non_transferable), + None, + None, + None, + platform_version, + ); + + let token_transfer_transition = BatchTransition::new_token_transfer_transition( + token_id, + identity.id(), + contract.id(), + 0, + 1337, + recipient.id(), + None, + None, + None, + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let token_transfer_serialized_transition = token_transfer_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + // The rule reads only the contract the action carries, so the mempool refuses it too. + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let check_tx_result = platform + .check_tx( + &token_transfer_serialized_transition, + CheckTxLevel::FirstTimeCheck, + &platform_ref, + platform_version, + ) + .expect("expected to be able to check tx"); + assert_matches!( + check_tx_result.errors.as_slice(), + [ConsensusError::StateError( + StateError::TokenNotTransferableError(_) + )] + ); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[token_transfer_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: error @ ConsensusError::StateError(StateError::TokenNotTransferableError(inner)), + .. + }] if *inner.token_id() == token_id && inner.action() == "transfer" && error.code() == 40726 + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let sender_balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token balance"); + assert_eq!(sender_balance, Some(100000)); + + let recipient_balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + recipient.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token balance"); + assert_eq!(recipient_balance, None); +} + +#[tokio::test] +async fn should_let_a_non_transferable_token_pay_its_own_contracts_document_by_burning_it() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(433); + + let platform_state = platform.state.load(); + + let (contract_owner, _, _) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let (buyer, signer, key) = setup_identity(&mut platform, 234, dash_to_credits!(0.1)); + + // The card game's "card" type burns 10 of its gold token (position 0) on create. + let data_contract_id = DataContract::generate_data_contract_id_v0(contract_owner.id(), 1); + let contract = setup_contract( + &platform.drive, + "tests/supporting_files/contract/crypto-card-game/crypto-card-game-in-game-currency-burn-tokens.json", + Some(data_contract_id.to_buffer()), + Some(contract_owner.id().to_buffer()), + Some(|data_contract: &mut DataContract| { + data_contract.set_created_at_epoch(Some(0)); + data_contract.set_created_at(Some(0)); + data_contract.set_created_at_block_height(Some(0)); + make_non_transferable( + data_contract + .token_configuration_mut(0) + .expect("expected the gold token"), + ); + }), + None, + Some(platform_version), + ); + let gold_token_id: Identifier = calculate_token_id(data_contract_id.as_bytes(), 0).into(); + + add_tokens_to_identity(&platform, gold_token_id, buyer.id(), 15); + + let card_document_type = contract + .document_type_for_name("card") + .expect("expected a card document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = card_document_type + .random_document_with_identifier_and_entropy( + &mut rng, + buyer.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(card_document_type, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + document.set("attack", 4.into()); + document.set("defense", 7.into()); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + card_document_type, + entropy.0, + &key, + 2, + 0, + Some(TokenPaymentInfo::V0(TokenPaymentInfoV0 { + payment_token_contract_id: None, + token_contract_position: 0, + minimum_token_cost: None, + maximum_token_cost: Some(10), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + })), + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let buyer_balance = platform + .drive + .fetch_identity_token_balance( + gold_token_id.to_buffer(), + buyer.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token balance"); + assert_eq!(buyer_balance, Some(5)); + + let token_supply = platform + .drive + .fetch_token_total_supply(gold_token_id.to_buffer(), None, platform_version) + .expect("expected to fetch total supply"); + assert_eq!(token_supply, Some(5)); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 61034f2ed85..8c57a2de026 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -3548,6 +3548,259 @@ mod tests { } } + /// A non-transferable token (protocol version 14) may pay only for its own contract's + /// documents, and only by being burned; it cannot have a shielded pool. + mod non_transferable_tokens { + use super::*; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::codes::ErrorWithCode; + use dpp::consensus::state::state_error::StateError; + use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; + + fn make_non_transferable(token_configuration: &mut TokenConfiguration) { + token_configuration.set_transferable(false); + } + + /// Registers the card game with its gold token (position 0) changed by + /// `modify_gold_token`, and the card's create cost set to `card_cost` in both the parsed + /// type and its schema. The fixture's purchase cost, which pays the owner in gold too, is + /// taken out so that the create cost is the only one charging gold. + async fn register_card_game( + platform: &mut TempPlatform, + modify_gold_token: impl FnOnce(&mut TokenConfiguration), + card_cost: DocumentActionTokenCost, + ) -> Vec { + let platform_version = PlatformVersion::latest(); + let platform_state = platform.state.load(); + + let (identity, contract_signer, contract_key) = + setup_identity(platform, 958, dash_to_credits!(1.0)); + + let mut data_contract = json_document_to_contract_with_ids( + "tests/supporting_files/contract/crypto-card-game/crypto-card-game-in-game-currency.json", + None, + None, + false, //no need to validate the data contracts in tests for drive + platform_version, + ) + .expect("expected to get json based contract"); + + modify_gold_token( + data_contract + .tokens_mut() + .expect("expected tokens") + .get_mut(&0) + .expect("expected the gold token"), + ); + + { + let document_type = data_contract + .document_types_mut() + .get_mut("card") + .expect("expected a document type with name card"); + document_type.set_document_creation_token_cost(Some(card_cost)); + document_type.set_document_purchase_token_cost(None); + let gas_fees_paid_by_int: u8 = card_cost.gas_fees_paid_by.into(); + let token_cost = document_type + .schema_mut() + .get_mut("tokenCost") + .expect("expected to get token cost") + .expect("expected token cost to be set"); + token_cost + .as_map_mut() + .expect("expected token cost to be a map") + .retain(|(action, _)| action.as_text() != Some("purchase")); + let creation_token_cost = token_cost + .get_mut("create") + .expect("expected to get creation token cost") + .expect("expected creation token cost to be set"); + if let Some(contract_id) = card_cost.contract_id { + creation_token_cost + .set_value("contractId", contract_id.into()) + .expect("expected to set token contract id"); + } + creation_token_cost + .set_value("tokenPosition", card_cost.token_contract_position.into()) + .expect("expected to set token position"); + creation_token_cost + .set_value("amount", card_cost.token_amount.into()) + .expect("expected to set token amount"); + creation_token_cost + .set_value("effect", Value::U8(card_cost.effect.into())) + .expect("expected to set token pay effect"); + creation_token_cost + .set_value("gasFeesPaidBy", gas_fees_paid_by_int.into()) + .expect("expected to set gas fees paid by"); + } + + let data_contract_create_transition = + DataContractCreateTransition::new_from_data_contract( + data_contract, + 1, + &identity.into_partial_identity_info(), + contract_key.id(), + &contract_signer, + platform_version, + None, + ) + .await + .expect("expect to create data contract create transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[data_contract_create_transition + .serialize_to_bytes() + .expect("expected serialized state transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result.execution_results().to_vec() + } + + fn own_gold_cost(effect: DocumentActionTokenEffect) -> DocumentActionTokenCost { + DocumentActionTokenCost { + contract_id: None, + token_contract_position: 0, + token_amount: 5, + effect, + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + optional: false, + } + } + + #[tokio::test] + async fn should_register_a_contract_burning_its_own_non_transferable_token() { + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let results = register_card_game( + &mut platform, + make_non_transferable, + own_gold_cost(DocumentActionTokenEffect::BurnToken), + ) + .await; + + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[tokio::test] + async fn should_refuse_a_contract_paying_its_own_non_transferable_token_to_its_owner() { + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let results = register_card_game( + &mut platform, + make_non_transferable, + own_gold_cost(DocumentActionTokenEffect::TransferTokenToContractOwner), + ) + .await; + + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::BasicError( + BasicError::NonTransferableTokenPaymentMustBurnError(inner) + ), + .. + }] if inner.token_contract_position() == 0 + && inner.action() == "create" + && error.code() == 10280 + ); + } + + #[tokio::test] + async fn should_refuse_a_contract_paying_an_external_non_transferable_token() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let (token_contract_owner, _, _) = + setup_identity(&mut platform, 11, dash_to_credits!(0.1)); + let (token_contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + token_contract_owner.id(), + Some(make_non_transferable), + None, + None, + None, + platform_version, + ); + + let results = register_card_game( + &mut platform, + |_| {}, + DocumentActionTokenCost { + contract_id: Some(token_contract.id()), + token_contract_position: 0, + token_amount: 5, + effect: DocumentActionTokenEffect::TransferTokenToContractOwner, + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + optional: false, + }, + ) + .await; + + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::StateError( + StateError::TokenNotTransferableError(inner) + ), + .. + }] if *inner.token_id() == token_id && error.code() == 40726 + ); + } + + #[tokio::test] + async fn should_refuse_a_non_transferable_token_with_a_shielded_pool_unpaid() { + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let results = register_card_game( + &mut platform, + |gold| { + gold.set_has_shielded_pool(true); + gold.set_transferable(false); + }, + own_gold_cost(DocumentActionTokenEffect::BurnToken), + ) + .await; + + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + error @ ConsensusError::BasicError( + BasicError::NonTransferableTokenShieldedPoolError(inner) + ) + )] if inner.token_contract_position() == 0 && error.code() == 10279 + ); + } + } + mod group_errors { use super::*; #[tokio::test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/state/v0/mod.rs index 8bcb659be6b..8e184ef0707 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/state/v0/mod.rs @@ -14,20 +14,23 @@ use dpp::consensus::state::identity::identity_for_token_configuration_not_found_ use dpp::consensus::state::state_error::StateError; use dpp::consensus::state::token::{ InvalidTokenPositionStateError, PreProgrammedDistributionTimestampInPastError, + TokenNotTransferableError, }; 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::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters; use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dpp::data_contract::associated_token::token_perpetual_distribution::distribution_recipient::TokenDistributionRecipient; use dpp::data_contract::associated_token::token_perpetual_distribution::methods::v0::TokenPerpetualDistributionV0Accessors; use dpp::data_contract::associated_token::token_pre_programmed_distribution::accessors::v0::TokenPreProgrammedDistributionV0Methods; use dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV1Getters}; use dpp::data_contract::group::accessors::v0::GroupV0Getters; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::data_contract_create_transition::accessors::DataContractCreateTransitionAccessorsV0; use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; +use dpp::tokens::calculate_token_id; use dpp::ProtocolError; use crate::error::execution::ExecutionError; @@ -365,6 +368,31 @@ impl DataContractCreateStateTransitionStateValidationV0 for DataContractCreateTr .into()], )); } + // An external token cost always pays the contract owner (an external + // burn is refused when the schema is parsed), which a non-transferable + // token forbids. Inert before protocol version 14: only a format 1 + // token configuration is non-transferable, and the pre-activation + // gate refuses that format on every earlier version, so no stored + // contract an earlier version reads here holds one. + if contract_tokens + .get(token_position) + .is_some_and(|configuration| !configuration.is_transferable()) + { + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_data_contract_create_transition(self), + ), + vec![TokenNotTransferableError::new( + calculate_token_id(contract_id.as_bytes(), *token_position) + .into(), + format!( + "document type {} token cost paid to the contract owner", + document_type.name() + ), + ) + .into()], + )); + } } } else { let bump_action = StateTransitionAction::BumpIdentityNonceAction( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index 6d16f54b1f4..55fb6da4b77 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -2027,6 +2027,125 @@ mod tests { assert_matches!(result, StateTransitionProofResult::VerifiedDataContract(_)); } + /// A contract update can only bring an external token cost in with a new document type, + /// and that cost pays the contract owner, which a non-transferable token refuses + /// (protocol version 14). + #[tokio::test] + async fn should_refuse_an_update_adding_a_type_paying_an_external_non_transferable_token() { + use crate::execution::validation::state_transition::tests::create_token_contract_with_owner_identity; + use dpp::consensus::codes::ErrorWithCode; + use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::platform_value::platform_value; + + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let (token_contract_owner, _, _) = + setup_identity(&mut platform, 11, dash_to_credits!(0.1)); + + let platform_state = platform.state.load(); + let platform_version = platform_state + .current_platform_version() + .expect("expected to get current platform version"); + + let (token_contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + token_contract_owner.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_transferable(false) + }), + None, + None, + None, + platform_version, + ); + + let mut data_contract = + get_data_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + data_contract.set_owner_id(identity.id()); + + platform + .drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply contract successfully"); + + let mut updated_data_contract = data_contract.clone(); + updated_data_contract.set_version(2); + updated_data_contract + .set_document_schema( + "tip", + platform_value!({ + "type": "object", + "properties": { + "message": {"type": "string", "position": 0, "maxLength": 40_u32}, + }, + "tokenCost": { + "create": { + "contractId": token_contract.id().to_buffer(), + "tokenPosition": 0_u64, + "amount": 1_u64, + } + }, + "additionalProperties": false, + }), + true, + &mut vec![], + platform_version, + ) + .expect("expected to add the tip document type"); + + let data_contract_update_transition = + DataContractUpdateTransition::new_from_data_contract( + updated_data_contract, + &identity.into_partial_identity_info(), + key.id(), + 2, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create data contract update transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[data_contract_update_transition + .serialize_to_bytes() + .expect("expected serialized state transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::StateError( + StateError::TokenNotTransferableError(inner) + ), + .. + }] if *inner.token_id() == token_id && error.code() == 40726 + ); + } + #[tokio::test] async fn test_data_contract_update_with_token_setting_identifier_that_does_exist() { let mut platform = TestPlatformBuilder::new() diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/state/v0/mod.rs index 34a30012f14..30f5fdbfbc5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/state/v0/mod.rs @@ -13,18 +13,20 @@ use dpp::consensus::state::identity::identity_for_token_configuration_not_found_ IdentityInTokenConfigurationNotFoundError, TokenConfigurationIdentityContext, }; use dpp::consensus::state::state_error::StateError; -use dpp::consensus::state::token::InvalidTokenPositionStateError; +use dpp::consensus::state::token::{InvalidTokenPositionStateError, TokenNotTransferableError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::accessors::v1::{DataContractV1Getters, DataContractV1Setters}; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters; use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dpp::data_contract::associated_token::token_perpetual_distribution::distribution_recipient::TokenDistributionRecipient; use dpp::data_contract::associated_token::token_perpetual_distribution::methods::v0::TokenPerpetualDistributionV0Accessors; use dpp::data_contract::associated_token::token_pre_programmed_distribution::accessors::v0::TokenPreProgrammedDistributionV0Methods; use dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV1Getters}; use dpp::data_contract::group::accessors::v0::GroupV0Getters; use dpp::data_contract::validate_update::DataContractUpdateValidationMethodsV0; +use dpp::tokens::calculate_token_id; use crate::error::execution::ExecutionError; use crate::execution::validation::state_transition::ValidationMode; @@ -436,6 +438,31 @@ impl DataContractUpdateStateTransitionStateValidationV0 for DataContractUpdateTr .into()], )); } + // An external token cost always pays the contract owner (an external + // burn is refused when the schema is parsed), which a non-transferable + // token forbids. Inert before protocol version 14: only a format 1 + // token configuration is non-transferable, and the pre-activation + // gate refuses that format on every earlier version, so no stored + // contract an earlier version reads here holds one. + if contract_tokens + .get(token_position) + .is_some_and(|configuration| !configuration.is_transferable()) + { + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_data_contract_update_transition(self), + ), + vec![TokenNotTransferableError::new( + calculate_token_id(contract_id.as_bytes(), *token_position) + .into(), + format!( + "document type {} token cost paid to the contract owner", + document_type.name() + ), + ) + .into()], + )); + } } } else { let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 27b43d69fe2..abbe5d82e7b 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2227,6 +2227,25 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// Full property schema validation and `deleteConstraints` are not added to restore. /// Earlier versions are unchanged: contract moderation is inactive before version 14. /// +/// 90. **Non-transferable tokens**: a format 1 token configuration +/// (`TokenConfigurationV1`) gains `transferable`, `true` when absent and +/// fixed at creation (no `TokenConfigUpdate` item). With `false` the batch +/// advanced structure validation 1 (`DRIVE_ABCI_VALIDATION_VERSIONS_V10`) +/// refuses every `TokenTransfer` from the contract the action carries +/// (`TokenNotTransferableError`, 40726, paid, and in check tx); the +/// document type parser, in place, refuses a cost in the contract's own +/// such token that pays the contract owner instead of burning it +/// (`NonTransferableTokenPaymentMustBurnError`, 10280); data contract +/// create and update state validation 0, in place, refuse a cost in +/// another contract's such token (40726), reusing the read that checks the +/// token exists, external burns being refused already (10261); and +/// `validate_shielded_pool_rules` refuses it with a shielded pool +/// (`NonTransferableTokenShieldedPoolError`, 10279, unpaid at the +/// pre-activation gate). Mints, claims, direct purchases, burns, freezes +/// and burn payments for the contract's own documents are unchanged. +/// Inert before this version: only format 1 can be non-transferable, and +/// the pre-activation gate refuses that format on every earlier version. +/// /// 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 diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 3629722e724..84b7362c412 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -66,7 +66,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ DataTriggerConditionError, DataTriggerExecutionError, DataTriggerInvalidResultError, }; use wasm_bindgen::{JsError, JsValue}; -use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, ContestedUniqueIndexWithUniqueIndexError, DataContractInvalidRequiredFieldsUpdateError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TokenShieldedPoolIncompatibleRulesError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; +use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, ContestedUniqueIndexWithUniqueIndexError, DataContractInvalidRequiredFieldsUpdateError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, NonTransferableTokenPaymentMustBurnError, NonTransferableTokenShieldedPoolError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TokenShieldedPoolIncompatibleRulesError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyConstraintViolatedError, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, DocumentPropertyNotGeneratedError, DocumentReferencePreimageInvalidError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; @@ -155,7 +155,7 @@ use dpp::consensus::state::identity::no_transfer_key_for_core_withdrawal_availab use dpp::consensus::state::identity::RecipientIdentityDoesNotExistError; use dpp::consensus::state::prefunded_specialized_balances::prefunded_specialized_balance_insufficient_error::PrefundedSpecializedBalanceInsufficientError; use dpp::consensus::state::prefunded_specialized_balances::prefunded_specialized_balance_not_found_error::PrefundedSpecializedBalanceNotFoundError; -use dpp::consensus::state::token::{IdentityDoesNotHaveEnoughTokenBalanceError, IdentityTokenAccountNotFrozenError, IdentityTokenAccountFrozenError, TokenIsPausedError, IdentityTokenAccountAlreadyFrozenError, UnauthorizedTokenActionError, TokenSettingMaxSupplyToLessThanCurrentSupplyError, TokenMintPastMaxSupplyError, NewTokensDestinationIdentityDoesNotExistError, NewAuthorizedActionTakerIdentityDoesNotExistError, NewAuthorizedActionTakerGroupDoesNotExistError, NewAuthorizedActionTakerMainGroupNotSetError, InvalidGroupPositionError, TokenAlreadyPausedError, TokenNotPausedError, InvalidTokenClaimPropertyMismatch, InvalidTokenClaimNoCurrentRewards, InvalidTokenClaimWrongClaimant, TokenTransferRecipientIdentityNotExistError, PreProgrammedDistributionTimestampInPastError, IdentityHasNotAgreedToPayRequiredTokenAmountError, RequiredTokenPaymentInfoNotSetError, IdentityTryingToPayWithWrongTokenError, TokenDirectPurchaseUserPriceTooLow, TokenAmountUnderMinimumSaleAmount, TokenNotForDirectSale, InvalidTokenPositionStateError, TokenOncePerIdentityDistributionAlreadyClaimedError, TokenShieldedPoolNotEnabledError, TokenShieldedPaymentAmountMismatchError, TokenShieldedPaymentNotRequiredError, GasFeesPaidByNotAllowedError, InconsistentGasFeesPaidByInBatchError}; +use dpp::consensus::state::token::{IdentityDoesNotHaveEnoughTokenBalanceError, IdentityTokenAccountNotFrozenError, IdentityTokenAccountFrozenError, TokenIsPausedError, IdentityTokenAccountAlreadyFrozenError, UnauthorizedTokenActionError, TokenSettingMaxSupplyToLessThanCurrentSupplyError, TokenMintPastMaxSupplyError, NewTokensDestinationIdentityDoesNotExistError, NewAuthorizedActionTakerIdentityDoesNotExistError, NewAuthorizedActionTakerGroupDoesNotExistError, NewAuthorizedActionTakerMainGroupNotSetError, InvalidGroupPositionError, TokenAlreadyPausedError, TokenNotPausedError, InvalidTokenClaimPropertyMismatch, InvalidTokenClaimNoCurrentRewards, InvalidTokenClaimWrongClaimant, TokenTransferRecipientIdentityNotExistError, PreProgrammedDistributionTimestampInPastError, IdentityHasNotAgreedToPayRequiredTokenAmountError, RequiredTokenPaymentInfoNotSetError, IdentityTryingToPayWithWrongTokenError, TokenDirectPurchaseUserPriceTooLow, TokenAmountUnderMinimumSaleAmount, TokenNotForDirectSale, InvalidTokenPositionStateError, TokenOncePerIdentityDistributionAlreadyClaimedError, TokenShieldedPoolNotEnabledError, TokenShieldedPaymentAmountMismatchError, TokenShieldedPaymentNotRequiredError, TokenNotTransferableError, GasFeesPaidByNotAllowedError, InconsistentGasFeesPaidByInBatchError}; use dpp::consensus::state::address_funds::{AddressDoesNotExistError, AddressInvalidNonceError, AddressNotEnoughFundsError, AddressesNotEnoughFundsError}; use dpp::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; @@ -522,6 +522,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::TokenShieldedPaymentNotRequiredError(e) => { generic_consensus_error!(TokenShieldedPaymentNotRequiredError, e).into() } + StateError::TokenNotTransferableError(e) => { + generic_consensus_error!(TokenNotTransferableError, e).into() + } StateError::IdentityInTokenConfigurationNotFoundError(e) => { generic_consensus_error!(IdentityInTokenConfigurationNotFoundError, e).into() } @@ -1404,6 +1407,12 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::TokenShieldedPoolIncompatibleRulesError(e) => { generic_consensus_error!(TokenShieldedPoolIncompatibleRulesError, e).into() } + BasicError::NonTransferableTokenShieldedPoolError(e) => { + generic_consensus_error!(NonTransferableTokenShieldedPoolError, e).into() + } + BasicError::NonTransferableTokenPaymentMustBurnError(e) => { + generic_consensus_error!(NonTransferableTokenPaymentMustBurnError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/tokens/configuration/token_configuration.rs b/packages/wasm-dpp2/src/tokens/configuration/token_configuration.rs index 5d4ce2bd42d..91b91071d36 100644 --- a/packages/wasm-dpp2/src/tokens/configuration/token_configuration.rs +++ b/packages/wasm-dpp2/src/tokens/configuration/token_configuration.rs @@ -42,6 +42,12 @@ struct TokenConfigurationOptions { has_shielded_pool: bool, #[serde(default)] minimum_pool_notes_for_outgoing: Option, + #[serde(default = "default_transferable")] + transferable: bool, +} + +fn default_transferable() -> bool { + true } #[wasm_bindgen(typescript_custom_section)] @@ -88,6 +94,15 @@ export interface TokenConfigurationOptions { * afterwards with a TokenConfigUpdate, under its own change control rules. */ minimumPoolNotesForOutgoing?: bigint; + /** + * Whether holders may move the token to another identity (protocol version 14+). + * Defaults to true. false produces a format-version-1 token configuration and cannot + * change after creation: every TokenTransfer is refused, a document type may charge the + * token only by burning it (so only its own contract's document types can use it), and + * the token cannot have a shielded pool. Minting, claims, direct purchases, burns and + * freezes work as configured. + */ + transferable?: boolean; } "#; @@ -128,13 +143,15 @@ fn write_minimum_pool_notes_for_outgoing( minimum_pool_notes_for_outgoing: Option, ) -> WasmDppResult<()> { match configuration { - TokenConfiguration::V1(v1) => { + // A format-version-1 configuration without a pool (a non-transferable token) has no + // threshold to hold either. + TokenConfiguration::V1(v1) if v1.has_shielded_pool => { v1.minimum_pool_notes_for_outgoing = minimum_pool_notes_for_outgoing; Ok(()) } // Clearing a threshold a pool-less token never had leaves it as it already is. - TokenConfiguration::V0(_) if minimum_pool_notes_for_outgoing.is_none() => Ok(()), - TokenConfiguration::V0(_) => Err(WasmDppError::invalid_argument( + _ if minimum_pool_notes_for_outgoing.is_none() => Ok(()), + _ => Err(WasmDppError::invalid_argument( "'minimumPoolNotesForOutgoing' needs 'hasShieldedPool': a token without a shielded \ pool holds no notes to count", )), @@ -201,6 +218,9 @@ impl TokenConfigurationWasm { if opts.has_shielded_pool { configuration.set_has_shielded_pool(true); } + if !opts.transferable { + configuration.set_transferable(false); + } // After the pool, since only a pooled configuration has somewhere to keep a threshold. write_minimum_pool_notes_for_outgoing( @@ -267,7 +287,15 @@ impl TokenConfigurationWasm { self.0.has_shielded_pool() } - /// The token configuration format version: 0 without a shielded pool, 1 with one. + /// Whether holders may move the token to another identity. Always true for format + /// version 0 configurations. + #[wasm_bindgen(getter = "transferable")] + pub fn transferable(&self) -> bool { + self.0.is_transferable() + } + + /// The token configuration format version: 0 for a transferable token without a shielded + /// pool, 1 for one with a pool or a non-transferable token. #[wasm_bindgen(getter = "formatVersion")] pub fn format_version(&self) -> u16 { self.0.format_version() @@ -389,13 +417,20 @@ impl TokenConfigurationWasm { /// Enabling upgrades a format-version-0 configuration to version 1 in place. /// - /// Disabling downgrades it back to version 0 and takes any + /// Disabling downgrades a transferable token back to version 0 and takes any /// `minimumPoolNotesForOutgoing` with it, since a token without a pool keeps no threshold. #[wasm_bindgen(setter = "hasShieldedPool")] pub fn set_has_shielded_pool(&mut self, has_shielded_pool: bool) { self.0.set_has_shielded_pool(has_shielded_pool) } + /// `false` upgrades a format-version-0 configuration to version 1 in place; `true` on a + /// token without a shielded pool downgrades it back to version 0. + #[wasm_bindgen(setter = "transferable")] + pub fn set_transferable(&mut self, transferable: bool) { + self.0.set_transferable(transferable) + } + /// `undefined` or `null` removes the threshold. Throws for a token that has no shielded /// pool, which has no threshold to hold. #[wasm_bindgen(setter = "minimumPoolNotesForOutgoing")] diff --git a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts index bd985e403d1..2ae020667d6 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -23,9 +23,10 @@ interface KeepsHistoryRulesOptions { isKeepingEmergencyActionHistory?: boolean; } -interface ShieldedPoolOptions { +interface FormatVersion1Options { hasShieldedPool?: boolean; minimumPoolNotesForOutgoing?: bigint; + transferable?: boolean; } describe('TokenConfiguration', () => { @@ -220,75 +221,75 @@ describe('TokenConfiguration', () => { }); }); - describe('minimumPoolNotesForOutgoing', () => { - // A configuration whose shielded pool settings are the only thing under test. Everything - // else is the least interesting valid value, and `hasShieldedPool` defaults to false so a - // test has to ask for a pool to get one. - function createConfiguration(shieldedPool: ShieldedPoolOptions = {}) { - const changeRules = createChangeControlRules(); + // A configuration whose format version 1 settings are the only thing under test. Everything + // else is the least interesting valid value, `hasShieldedPool` defaults to false so a test + // has to ask for a pool to get one, and `transferable` defaults to true. + function createConfiguration(formatVersion1: FormatVersion1Options = {}) { + const changeRules = createChangeControlRules(); - return new wasm.TokenConfiguration({ - conventions: new wasm.TokenConfigurationConvention( - { - en: { - $formatVersion: '0', - shouldCapitalize: true, - singularForm: 'TOKEN', - pluralForm: 'TOKENS', - }, + return new wasm.TokenConfiguration({ + conventions: new wasm.TokenConfigurationConvention( + { + en: { + $formatVersion: '0', + shouldCapitalize: true, + singularForm: 'TOKEN', + pluralForm: 'TOKENS', }, - 8, - ), - conventionsChangeRules: changeRules, - baseSupply: BigInt(1000), - maxSupply: undefined, - keepsHistory: createKeepsHistoryRules(), - isStartedAsPaused: false, - isAllowedTransferToFrozenBalance: false, - maxSupplyChangeRules: changeRules, - distributionRules: new wasm.TokenDistributionRules({ - perpetualDistribution: undefined, - perpetualDistributionRules: changeRules, - preProgrammedDistribution: undefined, - newTokensDestinationIdentityRules: changeRules, - mintingAllowChoosingDestination: true, - mintingAllowChoosingDestinationRules: changeRules, - changeDirectPurchasePricingRules: changeRules, - }), - marketplaceRules: new wasm.TokenMarketplaceRules( - wasm.TokenTradeMode.NotTradeable(), - changeRules, - ), - manualMintingRules: changeRules, - manualBurningRules: changeRules, - freezeRules: changeRules, - unfreezeRules: changeRules, - destroyFrozenFundsRules: changeRules, - emergencyActionRules: changeRules, - mainControlGroup: undefined, - mainControlGroupCanBeModified: wasm.AuthorizedActionTakers.NoOne(), - description: 'note', - ...shieldedPool, - }); - } - - // The configuration a data contract hands back, rebuilt from the one it was given. Reading - // a threshold off it proves the value reached the configuration itself: assigning to a - // property the wasm class does not define lands on the JavaScript object instead, where - // the same object's getter reads it straight back and agrees with anything. - function configurationHeldByContract(config: wasm.TokenConfiguration) { - const contract = new wasm.DataContract({ - ownerId, - identityNonce: BigInt(2), - schemas: object.documentSchemas, - definitions: null, - fullValidation: false, - tokens: { 0: config }, - }); + }, + 8, + ), + conventionsChangeRules: changeRules, + baseSupply: BigInt(1000), + maxSupply: undefined, + keepsHistory: createKeepsHistoryRules(), + isStartedAsPaused: false, + isAllowedTransferToFrozenBalance: false, + maxSupplyChangeRules: changeRules, + distributionRules: new wasm.TokenDistributionRules({ + perpetualDistribution: undefined, + perpetualDistributionRules: changeRules, + preProgrammedDistribution: undefined, + newTokensDestinationIdentityRules: changeRules, + mintingAllowChoosingDestination: true, + mintingAllowChoosingDestinationRules: changeRules, + changeDirectPurchasePricingRules: changeRules, + }), + marketplaceRules: new wasm.TokenMarketplaceRules( + wasm.TokenTradeMode.NotTradeable(), + changeRules, + ), + manualMintingRules: changeRules, + manualBurningRules: changeRules, + freezeRules: changeRules, + unfreezeRules: changeRules, + destroyFrozenFundsRules: changeRules, + emergencyActionRules: changeRules, + mainControlGroup: undefined, + mainControlGroupCanBeModified: wasm.AuthorizedActionTakers.NoOne(), + description: 'note', + ...formatVersion1, + }); + } + + // The configuration a data contract hands back, rebuilt from the one it was given. Reading + // a threshold off it proves the value reached the configuration itself: assigning to a + // property the wasm class does not define lands on the JavaScript object instead, where + // the same object's getter reads it straight back and agrees with anything. + function configurationHeldByContract(config: wasm.TokenConfiguration) { + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: object.documentSchemas, + definitions: null, + fullValidation: false, + tokens: { 0: config }, + }); - return (contract.tokens as Record)[0]; - } + return (contract.tokens as Record)[0]; + } + describe('minimumPoolNotesForOutgoing', () => { it('should keep a threshold asked for at creation', () => { const config = createConfiguration({ hasShieldedPool: true, @@ -352,4 +353,48 @@ describe('TokenConfiguration', () => { }).to.throw(/hasShieldedPool/); }); }); + + describe('transferable', () => { + it('should read a token as transferable unless it asks otherwise', () => { + const config = createConfiguration(); + + expect(config.transferable).to.equal(true); + expect(config.formatVersion).to.equal(0); + }); + + it('should make a non-transferable token a format version 1 configuration', () => { + const config = createConfiguration({ transferable: false }); + + expect(config.transferable).to.equal(false); + expect(config.hasShieldedPool).to.equal(false); + expect(config.formatVersion).to.equal(1); + expect(configurationHeldByContract(config).transferable).to.equal(false); + }); + + it('should return to format version 0 when made transferable again', () => { + const config = createConfiguration({ transferable: false }); + + config.transferable = true; + + expect(config.transferable).to.equal(true); + expect(config.formatVersion).to.equal(0); + }); + + it('should keep a token non-transferable when its shielded pool is removed', () => { + const config = createConfiguration({ transferable: false }); + + config.hasShieldedPool = true; + config.hasShieldedPool = false; + + expect(config.transferable).to.equal(false); + expect(config.formatVersion).to.equal(1); + }); + + it('should refuse a threshold for a non-transferable token without a shielded pool', () => { + expect(() => createConfiguration({ + transferable: false, + minimumPoolNotesForOutgoing: BigInt(5), + })).to.throw(/hasShieldedPool/); + }); + }); }); From a2dcb1b6e5c027f1661a0d3dfc3094676a412e9d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 9 Oct 2026 07:29:35 +0200 Subject: [PATCH 2/2] fix(platform)!: non-transferable token review follow-ups (PV14) - Removing a non-transferable token's pool clears the pool threshold and resets its rules even though the configuration stays at format 1; TokenConfiguration::set_minimum_pool_notes_for_outgoing holds the "a threshold needs a pool" rule and wasm-dpp2 delegates to it. - The pool threshold TokenConfigUpdate items are refused on a format 1 configuration without a pool. - Document-base state validation 2 refuses paying the contract owner in the contract's own non-transferable token at payment time too, from the configuration in hand. - The external token refusal names the document type and its tokenCost actions, through one shared helper, and the create and update loops look each position up once. - Tests: protocol version 13 still refuses a non-transferable token as an unsupported format; mint, claim and direct purchase still credit identities; the payment-time refusal; both orders of removing a pool; the burn fixture no longer carries a cost registration would refuse; shared test helpers. - Docs: v14 note renumbered to 89, base supply destination corrected. Co-Authored-By: Claude Opus 5.5 --- .../src/data-model/non-transferable-tokens.md | 17 +- .../token_configuration/accessors/mod.rs | 35 +- .../can_apply_token_configuration_item/mod.rs | 12 +- .../token_configuration/mod.rs | 104 +++++- .../token_configuration/v1/mod.rs | 4 +- .../token_configuration_item.rs | 4 +- .../token/token_not_transferable_error.rs | 7 +- .../state_v2/mod.rs | 31 +- .../batch/advanced_structure/v1/mod.rs | 2 +- .../batch/tests/token/non_transferable/mod.rs | 317 +++++++++++++++++- .../data_contract_common/mod.rs | 3 + .../non_transferable_token_cost.rs | 46 +++ .../data_contract_create/mod.rs | 50 ++- .../data_contract_create/state/v0/mod.rs | 71 ++-- .../data_contract_update/mod.rs | 20 +- .../data_contract_update/state/v0/mod.rs | 82 ++--- .../state_transition/state_transitions/mod.rs | 9 + .../rs-platform-version/src/version/v14.rs | 16 +- .../configuration/token_configuration.rs | 33 +- .../tests/unit/TokenConfiguration.spec.ts | 23 ++ 20 files changed, 719 insertions(+), 167 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/non_transferable_token_cost.rs diff --git a/book/src/data-model/non-transferable-tokens.md b/book/src/data-model/non-transferable-tokens.md index f2a9d9ee2a9..4794e0d71a9 100644 --- a/book/src/data-model/non-transferable-tokens.md +++ b/book/src/data-model/non-transferable-tokens.md @@ -28,8 +28,9 @@ contract update cannot change an existing token's configuration. ## What a non-transferable token still does - **Minting.** The minting rules decide who mints and whether they may choose the destination, - as for any token. This is how an issuer hands the token out; a base supply would sit with the - contract owner, who cannot send it on either. + as for any token. This is how an issuer hands the token out; a base supply is minted to + `newTokensDestinationIdentity`, or to the contract owner when that is unset, and whoever + receives it cannot send it on either. - **Distributions and claims.** Perpetual, pre-programmed and once-per-identity distributions mint to their recipients. - **Direct purchase.** A purchase mints the tokens to the buyer and pays the price in credits. @@ -42,7 +43,7 @@ contract update cannot change an existing token's configuration. | Operation | Refused with | Where | |---|---|---| | A `TokenTransfer` of the token | `TokenNotTransferableError` (40726) | Batch advanced structure validation, from the contract the action carries: the mempool refuses it, and a block charges the signer and bumps the nonce | -| A document type of the token's own contract charging it with `effect: 0` (pay the contract owner) | `NonTransferableTokenPaymentMustBurnError` (10280) | Contract create and update, when the document type is parsed | +| A document type of the token's own contract charging it with `effect: 0` (pay the contract owner) | `NonTransferableTokenPaymentMustBurnError` (10280) | Contract create and update, when the document type is parsed. A document payment refuses it again (`TokenNotTransferableError`, 40726) from the configuration the contract already carries, so a contract that bypassed registration cannot pay with it either | | A document type of another contract charging it | `TokenNotTransferableError` (40726) | Contract create and update state validation, which already reads the token's contract to check the token exists. Such a cost always pays the contract owner, since another contract's token cannot be burned (10261) | | `hasShieldedPool: true` | `NonTransferableTokenShieldedPoolError` (10279) | Contract create and update, before anything is charged | @@ -50,8 +51,14 @@ A shielded pool is refused because a note spent inside the pool can be unshielde identity, so shielding and unshielding would move the token between holders. Every rule is checked when the contract is registered or when the transfer arrives, and the flag -cannot change afterwards, so a document payment never has to look the flag up: a document type -that could pay the owner in a non-transferable token is never registered. +cannot change afterwards: a document type that could pay the owner in a non-transferable token +is never registered. A payment in the contract's own token checks the flag again at no read +cost; another contract's token is not read at payment time, since a cost in it was refused at +registration. + +A non-transferable token has no pool, so the `TokenConfigUpdate` items that change the pool's +`minimumPoolNotesForOutgoing` and its rules are refused for it, even though its configuration is +format version 1. ## Why not pause the token diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/mod.rs index 560b41e8ccd..c52ae867613 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/accessors/mod.rs @@ -8,7 +8,9 @@ use crate::data_contract::associated_token::token_configuration::accessors::v0:: use crate::data_contract::associated_token::token_configuration::accessors::v1::{ TokenConfigurationV1Getters, TokenConfigurationV1Setters, }; -use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; +use crate::data_contract::associated_token::token_configuration::v0::{ + default_change_control_rules, TokenConfigurationV0, +}; use crate::data_contract::associated_token::token_configuration::v1::TokenConfigurationV1; use crate::data_contract::associated_token::token_configuration::TokenConfiguration; use crate::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; @@ -280,6 +282,14 @@ impl TokenConfigurationV1Setters for TokenConfiguration { } TokenConfiguration::V1(v1) => { v1.set_has_shielded_pool(has_shielded_pool); + if !has_shielded_pool { + // The threshold and its rules only apply to a pool. A non-transferable + // token stays at V1 without one, so they are cleared here rather than left + // to the downgrade. + v1.minimum_pool_notes_for_outgoing = None; + v1.minimum_pool_notes_for_outgoing_change_rules = + default_change_control_rules(); + } self.downgrade_to_v0_if_unused(); } } @@ -308,8 +318,27 @@ impl TokenConfigurationV1Setters for TokenConfiguration { } impl TokenConfiguration { - /// Replaces a V1 configuration that has no pool and is transferable by its V0 base. The - /// pool's threshold and its rules go with it: they only apply to a pool. + /// Sets the shielded pool's outgoing notes threshold, which only a configuration that has a + /// pool can carry. A threshold asked for on a token without a pool (format 0, or a + /// non-transferable format 1 token) is refused, returning `false` and changing nothing; + /// `None` always succeeds and clears whatever threshold is held. + pub fn set_minimum_pool_notes_for_outgoing( + &mut self, + minimum_pool_notes_for_outgoing: Option, + ) -> bool { + match self { + TokenConfiguration::V1(v1) + if v1.has_shielded_pool || minimum_pool_notes_for_outgoing.is_none() => + { + v1.minimum_pool_notes_for_outgoing = minimum_pool_notes_for_outgoing; + true + } + TokenConfiguration::V0(_) => minimum_pool_notes_for_outgoing.is_none(), + TokenConfiguration::V1(_) => false, + } + } + + /// Replaces a V1 configuration that has no pool and is transferable by its V0 base. fn downgrade_to_v0_if_unused(&mut self) { if let TokenConfiguration::V1(v1) = self { if !v1.has_shielded_pool && v1.transferable { diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/methods/can_apply_token_configuration_item/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/methods/can_apply_token_configuration_item/mod.rs index c4af63982e5..85731c32e9c 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/methods/can_apply_token_configuration_item/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/methods/can_apply_token_configuration_item/mod.rs @@ -50,8 +50,9 @@ impl TokenConfiguration { impl TokenConfigurationV1 { /// Determines whether a `TokenConfigurationChangeItem` can be applied: the shielded pool's - /// threshold items under `minimum_pool_notes_for_outgoing_change_rules`, every other item - /// under the nested V0 configuration's rules. + /// threshold items under `minimum_pool_notes_for_outgoing_change_rules`, and only on a token + /// that has a pool (a non-transferable token is V1 without one), every other item under the + /// nested V0 configuration's rules. pub fn can_apply_token_configuration_item( &self, change_item: &TokenConfigurationChangeItem, @@ -63,6 +64,13 @@ impl TokenConfigurationV1 { ) -> bool { let rules = &self.minimum_pool_notes_for_outgoing_change_rules; match change_item { + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(_) + | TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup(_) + | TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup(_) + if !self.has_shielded_pool => + { + false + } TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(_) => { rules.can_make_change(contract_owner_id, main_group, groups, action_taker, goal) } diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs index fecec51256b..51e03195361 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs @@ -895,12 +895,16 @@ mod non_transferable_tests { use crate::consensus::codes::ErrorWithCode; use crate::consensus::ConsensusError; use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; + use crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; + use crate::data_contract::change_control_rules::v0::ChangeControlRulesV0; + use crate::group::action_taker::{ActionGoal, ActionTaker}; + use crate::prelude::Identifier; fn transferable() -> TokenConfiguration { TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()) } - fn non_transferable() -> TokenConfiguration { + pub(super) fn non_transferable() -> TokenConfiguration { let mut configuration = transferable(); configuration.set_transferable(false); configuration @@ -963,6 +967,96 @@ mod non_transferable_tests { ); } + /// Removing the pool of a non-transferable token keeps it at V1, so the threshold and its + /// rules, which only apply to a pool, are cleared rather than left on a token without one, + /// whichever of the two flags is set first. + #[test] + fn should_clear_the_pool_threshold_when_a_non_transferable_token_loses_its_pool() { + let pooled_with_threshold = || { + let mut configuration = transferable(); + configuration.set_has_shielded_pool(true); + assert!(configuration.set_minimum_pool_notes_for_outgoing(Some(9))); + let TokenConfiguration::V1(v1) = &mut configuration else { + panic!("a pooled configuration is V1"); + }; + v1.minimum_pool_notes_for_outgoing_change_rules + .set_authorized_to_make_change_action_takers(AuthorizedActionTakers::ContractOwner); + configuration + }; + + let mut transferable_first = pooled_with_threshold(); + transferable_first.set_transferable(false); + transferable_first.set_has_shielded_pool(false); + + let mut pool_first = pooled_with_threshold(); + pool_first.set_has_shielded_pool(false); + pool_first.set_transferable(false); + + for configuration in [transferable_first, pool_first] { + assert_eq!(configuration, non_transferable()); + assert_eq!(configuration.minimum_pool_notes_for_outgoing(), 0); + } + } + + /// The threshold setter takes a threshold only on a token with a pool and always clears. + #[test] + fn should_set_a_pool_threshold_only_on_a_token_with_a_pool() { + let mut configuration = non_transferable(); + assert!(!configuration.set_minimum_pool_notes_for_outgoing(Some(5))); + assert_eq!(configuration, non_transferable()); + assert!(configuration.set_minimum_pool_notes_for_outgoing(None)); + + let mut unpooled = transferable(); + assert!(!unpooled.set_minimum_pool_notes_for_outgoing(Some(5))); + assert!(unpooled.set_minimum_pool_notes_for_outgoing(None)); + assert_eq!(unpooled, transferable()); + + configuration.set_has_shielded_pool(true); + assert!(configuration.set_minimum_pool_notes_for_outgoing(Some(5))); + assert_eq!(configuration.minimum_pool_notes_for_outgoing(), 5); + } + + /// A non-transferable token is V1 without a pool, so V1 alone no longer means a pool: the + /// threshold items are refused on it even when its threshold rules name the actor. + #[test] + fn should_refuse_the_pool_threshold_items_on_a_token_without_a_pool() { + let owner = Identifier::from([1; 32]); + let mut configuration = non_transferable(); + let TokenConfiguration::V1(v1) = &mut configuration else { + panic!("a non-transferable configuration is V1"); + }; + v1.minimum_pool_notes_for_outgoing_change_rules = + ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::ContractOwner, + changing_authorized_action_takers_to_no_one_allowed: true, + changing_admin_action_takers_to_no_one_allowed: true, + self_changing_admin_action_takers_allowed: true, + }); + + for change in [ + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(10), + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup( + AuthorizedActionTakers::NoOne, + ), + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup( + AuthorizedActionTakers::NoOne, + ), + ] { + assert!( + !configuration.can_apply_token_configuration_item( + &change, + &owner, + None, + &BTreeMap::new(), + &ActionTaker::SingleIdentity(owner), + ActionGoal::ActionCompletion, + ), + "{change} must be refused on a token without a pool" + ); + } + } + #[test] fn should_round_trip_a_non_transferable_configuration_through_bincode() { let configuration = non_transferable(); @@ -982,17 +1076,11 @@ mod non_transferable_tests { feature = "serde-conversion" ))] mod non_transferable_json_tests { + use super::non_transferable_tests::non_transferable; use super::*; use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; use crate::serialization::{JsonConvertible, ValueConvertible}; - fn non_transferable() -> TokenConfiguration { - let mut configuration = - TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()); - configuration.set_transferable(false); - configuration - } - #[test] fn should_write_transferable_false_and_read_it_back_on_both_wires() { let configuration = non_transferable(); diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/mod.rs index b53c665f44e..a72edad5911 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/v1/mod.rs @@ -71,7 +71,9 @@ pub struct TokenConfigurationV1 { pub transferable: bool, } -fn default_transferable() -> bool { +/// The value of an omitted `transferable`: every token is transferable unless its configuration +/// says otherwise. +pub fn default_transferable() -> bool { true } diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration_item.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration_item.rs index 56487f0b2b2..c56045c3248 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration_item.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration_item.rs @@ -75,8 +75,8 @@ pub enum TokenConfigurationChangeItem { MarketplaceTradeModeControlGroup(AuthorizedActionTakers), MarketplaceTradeModeAdminGroup(AuthorizedActionTakers), MainControlGroup(Option), - /// The token shielded pool's outgoing notes threshold. Only a `TokenConfiguration::V1` - /// (a token with a pool) carries one, so only it can apply these three items. + /// The token shielded pool's outgoing notes threshold. Only a `TokenConfiguration::V1` that + /// has a pool can apply these three items; a non-transferable token is V1 without one. MinimumPoolNotesForOutgoing(u64), MinimumPoolNotesForOutgoingControlGroup(AuthorizedActionTakers), MinimumPoolNotesForOutgoingAdminGroup(AuthorizedActionTakers), diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_not_transferable_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_not_transferable_error.rs index c831b32fe7c..abed67d1310 100644 --- a/packages/rs-dpp/src/errors/consensus/state/token/token_not_transferable_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_not_transferable_error.rs @@ -9,8 +9,9 @@ use platform_value::Identifier; use thiserror::Error; /// The token's configuration sets `transferable: false`, and the refused operation would move -/// it to another identity: a `TokenTransfer`, or a contract whose document type pays the token -/// to its own owner. +/// it to another identity: a `TokenTransfer` ("a token transfer"), a contract whose document +/// type pays the token to its own owner (naming the type and the `tokenCost` actions), or a +/// document payment to the contract owner in the contract's own such token. #[derive( Error, Debug, @@ -24,7 +25,7 @@ use thiserror::Error; PlatformDeserializeUntrusted, DecodeUntrusted, )] -#[error("Token {token_id} is not transferable: {action} refused")] +#[error("Token {token_id} is not transferable, so {action} is refused")] #[platform_serialize(unversioned)] pub struct TokenNotTransferableError { /* 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 3e642f6e31d..adac3aacbc4 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 @@ -8,10 +8,11 @@ use crate::execution::validation::state_transition::batch::action_validation::do 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::consensus::state::token::{IdentityTokenAccountFrozenError, TokenNotTransferableError}; 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::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters; use dpp::identifier::Identifier; use dpp::tokens::calculate_token_id; use dpp::tokens::contract_info::v0::TokenContractInfoV0Accessors; @@ -42,8 +43,8 @@ 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 + /// checks, refuse paying the contract owner in the contract's own non-transferable token, + /// 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, @@ -84,6 +85,30 @@ impl DocumentBaseTransitionActionStateValidationV2 for DocumentBaseTransitionAct return Ok(validation_result); } + // A non-transferable token pays only by burning. Registration refuses any other cost in + // one and the flag is fixed at creation; this holds the rule at payment time too for the + // contract's own tokens, whose configuration is already in hand, so nothing is read. + if effect == DocumentActionTokenEffect::TransferTokenToContractOwner { + let contract = &self.data_contract_fetch_info_ref().contract; + let own_non_transferable = contract.tokens().iter().any(|(position, configuration)| { + !configuration.is_transferable() + && calculate_token_id(contract.id().as_bytes(), *position) + == token_id.to_buffer() + }); + if own_non_transferable { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenNotTransferableError::new( + token_id, + format!( + "a document {} token payment to the contract owner", + transition_type + ), + ) + .into(), + )); + } + } + let validation_result = validate_token_not_paused( platform, token_id, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs index 596a0e3a3a3..f9ec37005a7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -484,7 +484,7 @@ impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { bump_action, vec![TokenNotTransferableError::new( token_transition_action.base().token_id(), - "transfer".to_string(), + "a token transfer".to_string(), ) .into()], )); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs index 9db3fd14ac6..8413bfd6341 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs @@ -2,10 +2,17 @@ //! `TokenTransfer` moves it, and a document of its own contract may still charge it by burning. use super::*; use crate::execution::check_tx::CheckTxLevel; +use crate::execution::validation::state_transition::tests::make_token_non_transferable; use crate::platform_types::platform::PlatformRef; use dpp::consensus::codes::ErrorWithCode; use dpp::data_contract::accessors::v1::DataContractV1Setters; -use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; +use dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType; +use dpp::data_contract::associated_token::token_distribution_rules::accessors::v1::TokenDistributionRulesV1Setters; +use dpp::data_contract::associated_token::token_once_per_identity_distribution::v0::TokenOncePerIdentityDistributionV0; +use dpp::data_contract::associated_token::token_once_per_identity_distribution::TokenOncePerIdentityDistribution; +use dpp::data_contract::document_type::accessors::{ + DocumentTypeV0MutGetters, DocumentTypeV1Setters, +}; use dpp::data_contract::document_type::random_document::{ DocumentFieldFillSize, DocumentFieldFillType, }; @@ -15,10 +22,24 @@ use dpp::tokens::calculate_token_id; use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; use dpp::tokens::token_payment_info::v0::TokenPaymentInfoV0; use dpp::tokens::token_payment_info::TokenPaymentInfo; +use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use drive::util::test_helpers::setup_contract; -fn make_non_transferable(token_configuration: &mut TokenConfiguration) { - token_configuration.set_transferable(false); +/// Takes the card type's purchase cost out of a card game contract, in the parsed type and in +/// its schema: it pays the owner in gold, which a non-transferable gold forbids. +fn drop_card_purchase_cost(data_contract: &mut DataContract) { + let card = data_contract + .document_types_mut() + .get_mut("card") + .expect("expected a card document type"); + card.set_document_purchase_token_cost(None); + card.schema_mut() + .get_mut("tokenCost") + .expect("expected to get token cost") + .expect("expected token cost to be set") + .as_map_mut() + .expect("expected token cost to be a map") + .retain(|(action, _)| action.as_text() != Some("purchase")); } #[tokio::test] @@ -40,7 +61,7 @@ async fn should_refuse_a_transfer_of_a_non_transferable_token_in_the_mempool_and let (contract, token_id) = create_token_contract_with_owner_identity( &mut platform, identity.id(), - Some(make_non_transferable), + Some(make_token_non_transferable), None, None, None, @@ -113,7 +134,7 @@ async fn should_refuse_a_transfer_of_a_non_transferable_token_in_the_mempool_and [PaidConsensusError { error: error @ ConsensusError::StateError(StateError::TokenNotTransferableError(inner)), .. - }] if *inner.token_id() == token_id && inner.action() == "transfer" && error.code() == 40726 + }] if *inner.token_id() == token_id && inner.action() == "a token transfer" && error.code() == 40726 ); platform @@ -162,7 +183,9 @@ async fn should_let_a_non_transferable_token_pay_its_own_contracts_document_by_b let (buyer, signer, key) = setup_identity(&mut platform, 234, dash_to_credits!(0.1)); - // The card game's "card" type burns 10 of its gold token (position 0) on create. + // The card game's "card" type burns 10 of its gold token (position 0) on create. Its purchase + // cost pays the owner in gold, which registration refuses for a non-transferable token + // (10280), so it is taken out to keep the contract one the chain could hold. let data_contract_id = DataContract::generate_data_contract_id_v0(contract_owner.id(), 1); let contract = setup_contract( &platform.drive, @@ -173,11 +196,12 @@ async fn should_let_a_non_transferable_token_pay_its_own_contracts_document_by_b data_contract.set_created_at_epoch(Some(0)); data_contract.set_created_at(Some(0)); data_contract.set_created_at_block_height(Some(0)); - make_non_transferable( + make_token_non_transferable( data_contract .token_configuration_mut(0) .expect("expected the gold token"), ); + drop_card_purchase_cost(data_contract); }), None, Some(platform_version), @@ -276,3 +300,282 @@ async fn should_let_a_non_transferable_token_pay_its_own_contracts_document_by_b .expect("expected to fetch total supply"); assert_eq!(token_supply, Some(5)); } + +/// Registration refuses a cost that pays the owner in the contract's own non-transferable token +/// (10280), so the contract is inserted directly to show that a document payment refuses it +/// too, without reading anything. +#[tokio::test] +async fn should_refuse_paying_the_contract_owner_its_own_non_transferable_token_at_payment_time() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(433); + + let platform_state = platform.state.load(); + + let (contract_owner, _, _) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let (buyer, signer, key) = setup_identity(&mut platform, 234, dash_to_credits!(0.1)); + + // The card game's "card" type pays 10 of its gold token (position 0) to the owner on create. + let data_contract_id = DataContract::generate_data_contract_id_v0(contract_owner.id(), 1); + let contract = setup_contract( + &platform.drive, + "tests/supporting_files/contract/crypto-card-game/crypto-card-game-in-game-currency.json", + Some(data_contract_id.to_buffer()), + Some(contract_owner.id().to_buffer()), + Some(|data_contract: &mut DataContract| { + data_contract.set_created_at_epoch(Some(0)); + data_contract.set_created_at(Some(0)); + data_contract.set_created_at_block_height(Some(0)); + make_token_non_transferable( + data_contract + .token_configuration_mut(0) + .expect("expected the gold token"), + ); + }), + None, + Some(platform_version), + ); + let gold_token_id: Identifier = calculate_token_id(data_contract_id.as_bytes(), 0).into(); + + add_tokens_to_identity(&platform, gold_token_id, buyer.id(), 15); + + let card_document_type = contract + .document_type_for_name("card") + .expect("expected a card document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = card_document_type + .random_document_with_identifier_and_entropy( + &mut rng, + buyer.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(card_document_type, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + document.set("attack", 4.into()); + document.set("defense", 7.into()); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + card_document_type, + entropy.0, + &key, + 2, + 0, + Some(TokenPaymentInfo::V0(TokenPaymentInfoV0 { + payment_token_contract_id: None, + token_contract_position: 0, + minimum_token_cost: None, + maximum_token_cost: Some(10), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + })), + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenNotTransferableError(inner)), + .. + }] if *inner.token_id() == gold_token_id + && inner.action() == "a document create token payment to the contract owner" + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let buyer_balance = platform + .drive + .fetch_identity_token_balance( + gold_token_id.to_buffer(), + buyer.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token balance"); + assert_eq!(buyer_balance, Some(15)); +} + +/// What an issuer hands a non-transferable token out with: a mint to a chosen identity, a +/// once-per-identity claim and a direct purchase each still credit the identity. +#[tokio::test] +async fn should_still_hand_a_non_transferable_token_out_by_mint_claim_and_purchase() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(12345); + + let (owner, owner_signer, owner_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(1.0)); + let (minted_to, _, _) = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (claimant, claimant_signer, claimant_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (buyer, buyer_signer, buyer_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(10.0)); + + let owner_rules = || { + ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }) + }; + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + Some(|token_configuration: &mut TokenConfiguration| { + make_token_non_transferable(token_configuration); + token_configuration.set_manual_minting_rules(owner_rules()); + let distribution_rules = token_configuration.distribution_rules_mut(); + distribution_rules.set_minting_allow_choosing_destination(true); + distribution_rules.set_change_direct_purchase_pricing_rules(owner_rules()); + distribution_rules.set_once_per_identity_distribution(Some( + TokenOncePerIdentityDistribution::V0(TokenOncePerIdentityDistributionV0 { + amount: 100, + }), + )); + }), + None, + None, + None, + platform_version, + ); + + let platform_state = platform.state.load(); + + let mint = BatchTransition::new_token_mint_transition( + token_id, + owner.id(), + contract.id(), + 0, + 50, + Some(minted_to.id()), + None, + None, + &owner_key, + 2, + 0, + &owner_signer, + platform_version, + None, + ) + .await + .expect("expect to create the mint transition"); + let claim = BatchTransition::new_token_claim_transition( + token_id, + claimant.id(), + contract.id(), + 0, + TokenDistributionType::OncePerIdentity, + None, + &claimant_key, + 2, + 0, + &claimant_signer, + platform_version, + None, + ) + .await + .expect("expect to create the claim transition"); + let set_price = BatchTransition::new_token_change_direct_purchase_price_transition( + token_id, + owner.id(), + contract.id(), + 0, + Some(TokenPricingSchedule::SinglePrice(dash_to_credits!(1))), + None, + None, + &owner_key, + 3, + 0, + &owner_signer, + platform_version, + None, + ) + .await + .expect("expect to create the price transition"); + let purchase = BatchTransition::new_token_direct_purchase_transition( + token_id, + buyer.id(), + contract.id(), + 0, + 3, + dash_to_credits!(3), + &buyer_key, + 2, + 0, + &buyer_signer, + platform_version, + None, + ) + .await + .expect("expect to create the purchase transition"); + + for transition in [mint, claim, set_price, purchase] { + let processing_result = process_test_state_transition( + &mut platform, + transition, + &platform_state, + platform_version, + ); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + for (identity_id, expected) in [(minted_to.id(), 50), (claimant.id(), 100), (buyer.id(), 3)] { + let balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity_id.to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token balance"); + assert_eq!(balance, Some(expected)); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/mod.rs index 9be4e3f7a18..da1a9d5347e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/mod.rs @@ -4,6 +4,9 @@ pub(in crate::execution) mod check_tx_schema_depth; /// Validation of the reference declarations a contract's document types carry. pub mod data_contract_reference_validation; +/// The refusal of a document type's token cost in another contract's non-transferable token. +pub(in crate::execution) mod non_transferable_token_cost; + /// Runs a contract breaking a structural rule of the document type parser through `check_tx` /// and block processing, for the create and update suites. #[cfg(test)] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/non_transferable_token_cost.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/non_transferable_token_cost.rs new file mode 100644 index 00000000000..eae257abf33 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/non_transferable_token_cost.rs @@ -0,0 +1,46 @@ +use dpp::consensus::state::token::TokenNotTransferableError; +use dpp::consensus::ConsensusError; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV1Getters}; +use dpp::data_contract::document_type::DocumentType; +use dpp::data_contract::TokenContractPosition; +use dpp::identifier::Identifier; +use dpp::tokens::calculate_token_id; + +/// The refusal of `document_type`'s token costs in the non-transferable token at +/// `token_position` of the contract `contract_id`, naming each `tokenCost` action that charges +/// it. Such a cost always pays the contract owner, since another contract's token cannot be +/// burned. +pub(in crate::execution) fn external_non_transferable_token_cost_error( + document_type: &DocumentType, + contract_id: Identifier, + token_position: TokenContractPosition, +) -> ConsensusError { + let actions: Vec<&str> = [ + ("create", document_type.document_creation_token_cost()), + ("replace", document_type.document_replacement_token_cost()), + ("delete", document_type.document_deletion_token_cost()), + ("transfer", document_type.document_transfer_token_cost()), + ( + "update_price", + document_type.document_update_price_token_cost(), + ), + ("purchase", document_type.document_purchase_token_cost()), + ] + .into_iter() + .filter(|(_, cost)| { + cost.is_some_and(|cost| { + cost.contract_id == Some(contract_id) && cost.token_contract_position == token_position + }) + }) + .map(|(action, _)| action) + .collect(); + TokenNotTransferableError::new( + calculate_token_id(contract_id.as_bytes(), token_position).into(), + format!( + "document type {}'s {} token cost (it pays the contract owner)", + document_type.name(), + actions.join(", ") + ), + ) + .into() +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 8c57a2de026..117ca7326da 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -3552,16 +3552,13 @@ mod tests { /// documents, and only by being burned; it cannot have a shielded pool. mod non_transferable_tokens { use super::*; + use crate::execution::validation::state_transition::tests::make_token_non_transferable; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::consensus::codes::ErrorWithCode; use dpp::consensus::state::state_error::StateError; use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; - fn make_non_transferable(token_configuration: &mut TokenConfiguration) { - token_configuration.set_transferable(false); - } - /// Registers the card game with its gold token (position 0) changed by /// `modify_gold_token`, and the card's create cost set to `card_cost` in both the parsed /// type and its schema. The fixture's purchase cost, which pays the owner in gold too, is @@ -3570,8 +3567,8 @@ mod tests { platform: &mut TempPlatform, modify_gold_token: impl FnOnce(&mut TokenConfiguration), card_cost: DocumentActionTokenCost, + platform_version: &PlatformVersion, ) -> Vec { - let platform_version = PlatformVersion::latest(); let platform_state = platform.state.load(); let (identity, contract_signer, contract_key) = @@ -3685,6 +3682,35 @@ mod tests { } } + /// The in-place edits behind these refusals (the token cost parser and the create and + /// update state validation 0) cannot change protocol version 13: a non-transferable + /// token is a format 1 configuration, which the pre-activation gate refuses there, + /// unpaid, before any of them runs. + #[tokio::test] + async fn should_still_refuse_a_non_transferable_token_as_an_unsupported_format_at_protocol_version_13( + ) { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let results = register_card_game( + &mut platform, + make_token_non_transferable, + own_gold_cost(DocumentActionTokenEffect::TransferTokenToContractOwner), + platform_version, + ) + .await; + + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::UnsupportedVersionError(error)) + )] if error.received_version() == 1 && error.max_version() == 0 + ); + } + #[tokio::test] async fn should_register_a_contract_burning_its_own_non_transferable_token() { let mut platform = TestPlatformBuilder::new() @@ -3693,8 +3719,9 @@ mod tests { let results = register_card_game( &mut platform, - make_non_transferable, + make_token_non_transferable, own_gold_cost(DocumentActionTokenEffect::BurnToken), + PlatformVersion::latest(), ) .await; @@ -3712,8 +3739,9 @@ mod tests { let results = register_card_game( &mut platform, - make_non_transferable, + make_token_non_transferable, own_gold_cost(DocumentActionTokenEffect::TransferTokenToContractOwner), + PlatformVersion::latest(), ) .await; @@ -3742,7 +3770,7 @@ mod tests { let (token_contract, token_id) = create_token_contract_with_owner_identity( &mut platform, token_contract_owner.id(), - Some(make_non_transferable), + Some(make_token_non_transferable), None, None, None, @@ -3760,6 +3788,7 @@ mod tests { gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, optional: false, }, + PlatformVersion::latest(), ) .await; @@ -3770,7 +3799,9 @@ mod tests { StateError::TokenNotTransferableError(inner) ), .. - }] if *inner.token_id() == token_id && error.code() == 40726 + }] if *inner.token_id() == token_id + && inner.action() == "document type card's create token cost (it pays the contract owner)" + && error.code() == 40726 ); } @@ -3787,6 +3818,7 @@ mod tests { gold.set_transferable(false); }, own_gold_cost(DocumentActionTokenEffect::BurnToken), + PlatformVersion::latest(), ) .await; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/state/v0/mod.rs index 8e184ef0707..8909214963c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/state/v0/mod.rs @@ -1,7 +1,9 @@ +use crate::execution::validation::state_transition::state_transitions::data_contract_common::non_transferable_token_cost::external_non_transferable_token_cost_error; use crate::error::Error; use crate::execution::validation::state_transition::state_transitions::data_contract_common::check_tx_schema_depth::validate_document_schemas_depth_for_check_tx; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; +use dpp::consensus::ConsensusError; use dpp::block::block_info::BlockInfo; use std::collections::BTreeSet; @@ -14,7 +16,6 @@ use dpp::consensus::state::identity::identity_for_token_configuration_not_found_ use dpp::consensus::state::state_error::StateError; use dpp::consensus::state::token::{ InvalidTokenPositionStateError, PreProgrammedDistributionTimestampInPastError, - TokenNotTransferableError, }; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -25,12 +26,11 @@ use dpp::data_contract::associated_token::token_perpetual_distribution::distribu use dpp::data_contract::associated_token::token_perpetual_distribution::methods::v0::TokenPerpetualDistributionV0Accessors; use dpp::data_contract::associated_token::token_pre_programmed_distribution::accessors::v0::TokenPreProgrammedDistributionV0Methods; use dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV1Getters}; +use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; use dpp::data_contract::group::accessors::v0::GroupV0Getters; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::data_contract_create_transition::accessors::DataContractCreateTransitionAccessorsV0; use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; -use dpp::tokens::calculate_token_id; use dpp::ProtocolError; use crate::error::execution::ExecutionError; @@ -354,45 +354,38 @@ impl DataContractCreateStateTransitionStateValidationV0 for DataContractCreateTr if let Some(fetch_info) = contract_fetch_info.1 { let contract_tokens = fetch_info.contract.tokens(); for token_position in &token_positions { - if !contract_tokens.contains_key(token_position) { - return Ok(ConsensusValidationResult::new_with_data_and_errors( - StateTransitionAction::BumpIdentityNonceAction( - BumpIdentityNonceAction::from_borrowed_data_contract_create_transition(self), - ), - vec![StateError::InvalidTokenPositionStateError( - InvalidTokenPositionStateError::new( - contract_tokens.last_key_value().map(|(token_contract_position,_)| *token_contract_position), - *token_position, + let error: ConsensusError = match contract_tokens.get(token_position) { + None => StateError::InvalidTokenPositionStateError( + InvalidTokenPositionStateError::new( + contract_tokens.last_key_value().map( + |(token_contract_position, _)| *token_contract_position, ), - ) - .into()], - )); - } - // An external token cost always pays the contract owner (an external - // burn is refused when the schema is parsed), which a non-transferable - // token forbids. Inert before protocol version 14: only a format 1 - // token configuration is non-transferable, and the pre-activation - // gate refuses that format on every earlier version, so no stored - // contract an earlier version reads here holds one. - if contract_tokens - .get(token_position) - .is_some_and(|configuration| !configuration.is_transferable()) - { - return Ok(ConsensusValidationResult::new_with_data_and_errors( - StateTransitionAction::BumpIdentityNonceAction( - BumpIdentityNonceAction::from_borrowed_data_contract_create_transition(self), + *token_position, ), - vec![TokenNotTransferableError::new( - calculate_token_id(contract_id.as_bytes(), *token_position) - .into(), - format!( - "document type {} token cost paid to the contract owner", - document_type.name() - ), + ) + .into(), + // An external token cost always pays the contract owner (an + // external burn is refused when the schema is parsed), which a + // non-transferable token forbids. Inert before protocol version + // 14: only a format 1 token configuration is non-transferable, and + // the pre-activation gate refuses that format on every earlier + // version, so no stored contract an earlier version reads here + // holds one. + Some(configuration) if !configuration.is_transferable() => { + external_non_transferable_token_cost_error( + document_type, + contract_id, + *token_position, ) - .into()], - )); - } + } + Some(_) => continue, + }; + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_data_contract_create_transition(self), + ), + vec![error], + )); } } else { let bump_action = StateTransitionAction::BumpIdentityNonceAction( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index 55fb6da4b77..856f26587ce 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -1863,6 +1863,12 @@ mod tests { mod token_tests { use super::*; + use crate::execution::validation::state_transition::tests::{ + create_token_contract_with_owner_identity, make_token_non_transferable, + }; + use dpp::consensus::codes::ErrorWithCode; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::platform_value::platform_value; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::UnpaidConsensusError; use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; use dpp::balances::credits::TokenAmount; @@ -2032,12 +2038,6 @@ mod tests { /// (protocol version 14). #[tokio::test] async fn should_refuse_an_update_adding_a_type_paying_an_external_non_transferable_token() { - use crate::execution::validation::state_transition::tests::create_token_contract_with_owner_identity; - use dpp::consensus::codes::ErrorWithCode; - use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; - use dpp::data_contract::schema::DataContractSchemaMethodsV0; - use dpp::platform_value::platform_value; - let mut platform = TestPlatformBuilder::new() .build_with_mock_rpc() .set_initial_state_structure(); @@ -2054,9 +2054,7 @@ mod tests { let (token_contract, token_id) = create_token_contract_with_owner_identity( &mut platform, token_contract_owner.id(), - Some(|token_configuration: &mut TokenConfiguration| { - token_configuration.set_transferable(false) - }), + Some(make_token_non_transferable), None, None, None, @@ -2142,7 +2140,9 @@ mod tests { StateError::TokenNotTransferableError(inner) ), .. - }] if *inner.token_id() == token_id && error.code() == 40726 + }] if *inner.token_id() == token_id + && inner.action() == "document type tip's create token cost (it pays the contract owner)" + && error.code() == 40726 ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/state/v0/mod.rs index 30f5fdbfbc5..0026f8bd102 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/state/v0/mod.rs @@ -1,7 +1,9 @@ +use crate::execution::validation::state_transition::state_transitions::data_contract_common::non_transferable_token_cost::external_non_transferable_token_cost_error; use crate::error::Error; use crate::execution::validation::state_transition::state_transitions::data_contract_common::check_tx_schema_depth::validate_document_schemas_depth_for_check_tx; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; +use dpp::consensus::ConsensusError; use dpp::block::block_info::BlockInfo; use std::collections::BTreeSet; @@ -13,7 +15,7 @@ use dpp::consensus::state::identity::identity_for_token_configuration_not_found_ IdentityInTokenConfigurationNotFoundError, TokenConfigurationIdentityContext, }; use dpp::consensus::state::state_error::StateError; -use dpp::consensus::state::token::{InvalidTokenPositionStateError, TokenNotTransferableError}; +use dpp::consensus::state::token::InvalidTokenPositionStateError; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::accessors::v1::{DataContractV1Getters, DataContractV1Setters}; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; @@ -23,10 +25,9 @@ use dpp::data_contract::associated_token::token_perpetual_distribution::distribu use dpp::data_contract::associated_token::token_perpetual_distribution::methods::v0::TokenPerpetualDistributionV0Accessors; use dpp::data_contract::associated_token::token_pre_programmed_distribution::accessors::v0::TokenPreProgrammedDistributionV0Methods; use dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV1Getters}; +use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; use dpp::data_contract::group::accessors::v0::GroupV0Getters; use dpp::data_contract::validate_update::DataContractUpdateValidationMethodsV0; -use dpp::tokens::calculate_token_id; use crate::error::execution::ExecutionError; use crate::execution::validation::state_transition::ValidationMode; @@ -417,52 +418,41 @@ impl DataContractUpdateStateTransitionStateValidationV0 for DataContractUpdateTr if let Some(fetch_info) = contract_fetch_info.1 { let contract_tokens = fetch_info.contract.tokens(); for token_position in &token_positions { - if !contract_tokens.contains_key(token_position) { - let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( - BumpIdentityDataContractNonceAction::from_borrowed_data_contract_update_transition( - self, - ), - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - bump_action, - vec![StateError::InvalidTokenPositionStateError( - InvalidTokenPositionStateError::new( - contract_tokens.last_key_value().map( - |(token_contract_position, _)| { - *token_contract_position - }, - ), - *token_position, + let error: ConsensusError = match contract_tokens.get(token_position) { + None => StateError::InvalidTokenPositionStateError( + InvalidTokenPositionStateError::new( + contract_tokens.last_key_value().map( + |(token_contract_position, _)| *token_contract_position, ), - ) - .into()], - )); - } - // An external token cost always pays the contract owner (an external - // burn is refused when the schema is parsed), which a non-transferable - // token forbids. Inert before protocol version 14: only a format 1 - // token configuration is non-transferable, and the pre-activation - // gate refuses that format on every earlier version, so no stored - // contract an earlier version reads here holds one. - if contract_tokens - .get(token_position) - .is_some_and(|configuration| !configuration.is_transferable()) - { - return Ok(ConsensusValidationResult::new_with_data_and_errors( - StateTransitionAction::BumpIdentityDataContractNonceAction( - BumpIdentityDataContractNonceAction::from_borrowed_data_contract_update_transition(self), + *token_position, ), - vec![TokenNotTransferableError::new( - calculate_token_id(contract_id.as_bytes(), *token_position) - .into(), - format!( - "document type {} token cost paid to the contract owner", - document_type.name() - ), + ) + .into(), + // An external token cost always pays the contract owner (an + // external burn is refused when the schema is parsed), which a + // non-transferable token forbids. Inert before protocol version + // 14: only a format 1 token configuration is non-transferable, and + // the pre-activation gate refuses that format on every earlier + // version, so no stored contract an earlier version reads here + // holds one. + Some(configuration) if !configuration.is_transferable() => { + external_non_transferable_token_cost_error( + document_type, + contract_id, + *token_position, ) - .into()], - )); - } + } + Some(_) => continue, + }; + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_data_contract_update_transition( + self, + ), + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![error], + )); } } else { let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 71d3753fbe2..4574d230cbf 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -126,6 +126,7 @@ pub(crate) mod test_helpers; #[cfg(test)] pub(in crate::execution) mod tests { use crate::rpc::core::MockCoreRPCLike; + use dpp::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; use dpp::block::block_info::BlockInfo; use dpp::data_contracts::SystemDataContract; @@ -2837,6 +2838,14 @@ pub(in crate::execution) mod tests { ) } + /// Makes a token non-transferable (protocol version 14), as a `token_configuration_modification` + /// for the token contract helpers here. + pub(in crate::execution) fn make_token_non_transferable( + token_configuration: &mut TokenConfiguration, + ) { + token_configuration.set_transferable(false); + } + /// Like [`create_token_contract_with_owner_identity`], with the contract's creation epoch /// settable as well; a perpetual distribution counted in epochs starts from it. pub(in crate::execution) fn create_token_contract_with_owner_identity_with_start_epoch( diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 6c7bf5f75c6..f32b9bdf04d 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2244,7 +2244,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// Inert before this version: the earlier meta-schemas refuse /// `propertyConstraints` and their parsers ignore it. /// -/// 90. **Non-transferable tokens**: a format 1 token configuration +/// 89. **Non-transferable tokens**: a format 1 token configuration /// (`TokenConfigurationV1`) gains `transferable`, `true` when absent and /// fixed at creation (no `TokenConfigUpdate` item). With `false` the batch /// advanced structure validation 1 (`DRIVE_ABCI_VALIDATION_VERSIONS_V10`) @@ -2255,11 +2255,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (`NonTransferableTokenPaymentMustBurnError`, 10280); data contract /// create and update state validation 0, in place, refuse a cost in /// another contract's such token (40726), reusing the read that checks the -/// token exists, external burns being refused already (10261); and -/// `validate_shielded_pool_rules` refuses it with a shielded pool -/// (`NonTransferableTokenShieldedPoolError`, 10279, unpaid at the -/// pre-activation gate). Mints, claims, direct purchases, burns, freezes -/// and burn payments for the contract's own documents are unchanged. +/// token exists, external burns being refused already (10261); +/// document-base state validation 2 refuses a payment to the contract +/// owner in the contract's own such token again (40726), from the +/// configuration in hand; `validate_shielded_pool_rules` refuses it with a +/// shielded pool (`NonTransferableTokenShieldedPoolError`, 10279, unpaid +/// at the pre-activation gate); and the pool threshold +/// `TokenConfigUpdate` items are refused on a format 1 configuration +/// without a pool. Mints, claims, direct purchases, burns, freezes and +/// burn payments for the contract's own documents are unchanged. /// Inert before this version: only format 1 can be non-transferable, and /// the pre-activation gate refuses that format on every earlier version. /// diff --git a/packages/wasm-dpp2/src/tokens/configuration/token_configuration.rs b/packages/wasm-dpp2/src/tokens/configuration/token_configuration.rs index 91b91071d36..b973fa7de97 100644 --- a/packages/wasm-dpp2/src/tokens/configuration/token_configuration.rs +++ b/packages/wasm-dpp2/src/tokens/configuration/token_configuration.rs @@ -17,6 +17,7 @@ use dpp::data_contract::associated_token::token_configuration::accessors::v1::{ TokenConfigurationV1Getters, TokenConfigurationV1Setters, }; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; +use dpp::data_contract::associated_token::token_configuration::v1::default_transferable; use dpp::data_contract::{GroupContractPosition, TokenConfiguration, TokenContractPosition}; use dpp::prelude::Identifier; use dpp::tokens::calculate_token_id; @@ -46,10 +47,6 @@ struct TokenConfigurationOptions { transferable: bool, } -fn default_transferable() -> bool { - true -} - #[wasm_bindgen(typescript_custom_section)] const TS_TYPES: &str = r#" export interface TokenConfigurationOptions { @@ -130,31 +127,23 @@ impl From for TokenConfiguration { impl_try_from_js_value!(TokenConfigurationWasm, "TokenConfiguration"); -/// Writes the shielded pool's outgoing notes threshold, which only a configuration that has a -/// pool carries at all. +/// Writes the shielded pool's outgoing notes threshold through +/// `TokenConfiguration::set_minimum_pool_notes_for_outgoing`, which only a configuration that +/// has a pool accepts. /// -/// A threshold asked for on a token without a pool is refused rather than stored. The only -/// place to keep it would be a format-version-1 configuration with no pool, which contradicts -/// how the two are kept canonical — a token without a pool serializes as format version 0, so -/// that every protocol version accepts it — and which nothing would ever read. Dropping it -/// quietly is worse still: the caller asked for a guard and would get none. +/// A threshold asked for on a token without a pool is refused rather than dropped quietly: the +/// caller asked for a guard and would get none. fn write_minimum_pool_notes_for_outgoing( configuration: &mut TokenConfiguration, minimum_pool_notes_for_outgoing: Option, ) -> WasmDppResult<()> { - match configuration { - // A format-version-1 configuration without a pool (a non-transferable token) has no - // threshold to hold either. - TokenConfiguration::V1(v1) if v1.has_shielded_pool => { - v1.minimum_pool_notes_for_outgoing = minimum_pool_notes_for_outgoing; - Ok(()) - } - // Clearing a threshold a pool-less token never had leaves it as it already is. - _ if minimum_pool_notes_for_outgoing.is_none() => Ok(()), - _ => Err(WasmDppError::invalid_argument( + if configuration.set_minimum_pool_notes_for_outgoing(minimum_pool_notes_for_outgoing) { + Ok(()) + } else { + Err(WasmDppError::invalid_argument( "'minimumPoolNotesForOutgoing' needs 'hasShieldedPool': a token without a shielded \ pool holds no notes to count", - )), + )) } } diff --git a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts index 2ae020667d6..0123b0f8f67 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -390,6 +390,29 @@ describe('TokenConfiguration', () => { expect(config.formatVersion).to.equal(1); }); + it('should clear the threshold when a non-transferable token loses its shielded pool', () => { + const config = createConfiguration({ + transferable: false, + hasShieldedPool: true, + minimumPoolNotesForOutgoing: BigInt(5), + }); + + config.hasShieldedPool = false; + + expect(config.transferable).to.equal(false); + expect(config.formatVersion).to.equal(1); + expect(config.minimumPoolNotesForOutgoing).to.equal(BigInt(0)); + }); + + it('should accept clearing the threshold of a non-transferable token without a pool', () => { + const config = createConfiguration({ transferable: false }); + + config.minimumPoolNotesForOutgoing = undefined; + + expect(config.minimumPoolNotesForOutgoing).to.equal(BigInt(0)); + expect(config.formatVersion).to.equal(1); + }); + it('should refuse a threshold for a non-transferable token without a shielded pool', () => { expect(() => createConfiguration({ transferable: false,