From f0699bcc39ee796c16df8264254609a80d194278 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 02:17:43 +0700 Subject: [PATCH 1/8] feat(platform)!: random token transfer and mint amounts A token transfer or mint can move an amount drawn from the block's random beacon (protocol version 15): version 1 of each transition adds `random_minimum_amount`, and the amount moved is drawn from it up to `amount`. - dpp: the version 1 transitions, active from protocol version 15; structure validation of the range; a random mint must name its recipient and may not use a group action (`RandomTokenMintNotAllowedError`). - drive: execution reserves the maximum in the token marketplace escrow (sender -maximum or supply +maximum, recipient +0), so token conservation holds throughout; `Drive::settle_random_token_amounts` draws the amount, pays the recipient, releases the rest and rewrites the history amount. The draw is shared with document properties (`drive::random_beacon`). Proof verification takes the drawn amount from the proved history document after checking it lies in the requested range. - clients: rs-sdk builders, wasm-dpp2 and the wallet summary. Nothing calls the settlement yet: the beacon arrives through Tenderdash's ApplyBlockRandomness ABCI method in a follow-up. Refs #4682 Co-Authored-By: Claude Opus 5.5 --- .../src/errors/consensus/basic/basic_error.rs | 20 +- .../src/errors/consensus/basic/token/mod.rs | 2 + .../random_token_mint_not_allowed_error.rs | 58 ++ packages/rs-dpp/src/errors/consensus/codes.rs | 1 + packages/rs-dpp/src/state_transition/mod.rs | 118 ++- .../token_mint_transition/mod.rs | 74 +- .../token_mint_transition/v0/v0_methods.rs | 20 + .../token_mint_transition/v0_methods.rs | 95 ++- .../token_mint_transition/v1/mod.rs | 115 +++ .../token_mint_transition/v1/v1_methods.rs | 101 +++ .../validate_structure/v0/mod.rs | 123 ++- .../token_transfer_transition/mod.rs | 77 ++ .../v0/v0_methods.rs | 21 + .../token_transfer_transition/v0_methods.rs | 105 +++ .../token_transfer_transition/v1/mod.rs | 130 +++ .../validate_structure/v0/mod.rs | 78 +- .../document/batch_transition/methods/mod.rs | 7 +- .../batch_transition/v1/v1_methods.rs | 12 +- .../batch/tests/token/mod.rs | 1 + .../batch/tests/token/random_amount/mod.rs | 409 +++++++++ .../draw_random_document_properties/v0/mod.rs | 58 +- .../v1/mod.rs | 5 +- packages/rs-drive/src/drive/mod.rs | 5 + .../rs-drive/src/drive/random_beacon/mod.rs | 122 +++ packages/rs-drive/src/drive/tokens/mod.rs | 5 + .../tokens/settle_random_token_amounts/mod.rs | 152 ++++ .../settle_random_token_amounts/v0/mod.rs | 788 ++++++++++++++++++ .../batch/token/token_mint_transition.rs | 39 +- .../batch/token/token_transfer_transition.rs | 38 +- .../batch/token/token_transition.rs | 12 +- .../token_mint_transition_action/mod.rs | 9 + .../transformer.rs | 26 + .../token_mint_transition_action/v0/mod.rs | 19 +- .../v0/transformer.rs | 114 +++ .../token_transfer_transition_action/mod.rs | 7 + .../transformer.rs | 26 + .../v0/mod.rs | 16 +- .../v0/transformer.rs | 83 ++ .../token_transition_action_type.rs | 4 + .../state_transition_action/batch/tests.rs | 2 + .../src/util/batch/drive_op_batch/mod.rs | 21 + .../src/util/batch/drive_op_batch/token.rs | 183 ++++ .../v0/mod.rs | 24 + .../drive_token_method_versions/mod.rs | 5 + .../drive_token_method_versions/v1.rs | 3 + .../drive_token_method_versions/v3.rs | 6 +- .../src/version/drive_versions/v10.rs | 5 +- .../network/state_transition_summary.rs | 43 + packages/rs-sdk-ffi/src/document/helpers.rs | 2 + packages/rs-sdk-ffi/src/identity/helpers.rs | 1 + packages/rs-sdk-ffi/src/token/utils.rs | 2 + .../src/platform/tokens/builders/mint.rs | 39 +- .../src/platform/tokens/builders/transfer.rs | 39 +- .../src/errors/consensus/consensus_error.rs | 5 +- .../batch/token_transitions/token_mint.rs | 49 +- .../batch/token_transitions/token_transfer.rs | 52 +- 56 files changed, 3475 insertions(+), 101 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/basic/token/random_token_mint_not_allowed_error.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/v1_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/random_amount/mod.rs create mode 100644 packages/rs-drive/src/drive/random_beacon/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs create mode 100644 packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs 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 c6d1b7cf184..5d2cdab06a3 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -135,7 +135,7 @@ use crate::consensus::basic::token::{ InvalidTokenDistributionTimeIntervalNotMinuteAlignedError, InvalidTokenDistributionTimeIntervalTooShortError, InvalidTokenIdError, InvalidTokenNoteTooBigError, InvalidTokenOncePerIdentityDistributionAmountError, - InvalidTokenPositionError, MissingDefaultLocalizationError, + InvalidTokenPositionError, MissingDefaultLocalizationError, RandomTokenMintNotAllowedError, TokenNoteOnlyAllowedWhenProposerError, TokenPricingScheduleEmptyError, TokenTransferToOurselfError, }; @@ -891,6 +891,11 @@ pub enum BasicError { // A Batch document transition on a private document type (protocol version 15). #[error(transparent)] DocumentTypePrivateError(DocumentTypePrivateError), + + // A token mint asking for a random amount it cannot take: a group action, or one leaving + // its recipient to the token's configuration (protocol version 15). + #[error(transparent)] + RandomTokenMintNotAllowedError(RandomTokenMintNotAllowedError), } impl From for ConsensusError { @@ -1129,14 +1134,23 @@ mod tests { )), 209 ); - // A Batch document transition on a private document type (protocol version 15): the - // tail of the enum. + // A Batch document transition on a private document type (protocol version 15). assert_eq!( discriminant_of(BasicError::DocumentTypePrivateError( DocumentTypePrivateError::new(Identifier::from([1; 32]), "entry".to_string()) )), 210 ); + // A token mint refusing a random amount (protocol version 15): the tail of the enum. + assert_eq!( + discriminant_of(BasicError::RandomTokenMintNotAllowedError( + RandomTokenMintNotAllowedError::new( + Identifier::from([1; 32]), + "reason".to_string() + ) + )), + 211 + ); } /// 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/token/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs index 249807c3af7..d7a6691defa 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs @@ -13,6 +13,7 @@ mod invalid_token_note_too_big_error; mod invalid_token_once_per_identity_distribution_amount_error; mod invalid_token_position_error; mod missing_default_localization; +mod random_token_mint_not_allowed_error; mod token_note_only_allowed_on_proposer_error; mod token_pricing_schedule_empty_error; mod token_transfer_to_ourselves_error; @@ -32,6 +33,7 @@ pub use invalid_token_note_too_big_error::*; pub use invalid_token_once_per_identity_distribution_amount_error::*; pub use invalid_token_position_error::*; pub use missing_default_localization::*; +pub use random_token_mint_not_allowed_error::*; pub use token_note_only_allowed_on_proposer_error::*; pub use token_pricing_schedule_empty_error::*; pub use token_transfer_to_ourselves_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/random_token_mint_not_allowed_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/random_token_mint_not_allowed_error.rs new file mode 100644 index 00000000000..909cd4a9f83 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/token/random_token_mint_not_allowed_error.rs @@ -0,0 +1,58 @@ +use crate::consensus::basic::BasicError; +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; + +/// A token mint asking for a random amount (protocol version 15) that cannot take one: it is a +/// group action, whose members would weigh an amount nobody knows, or it leaves its recipient +/// to the token's configuration, which the payout must not read after the block executes the +/// mint. `reason` says which. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("A mint of token {token_id} cannot issue a random amount: {reason}")] +#[platform_serialize(unversioned)] +pub struct RandomTokenMintNotAllowedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_id: Identifier, + reason: String, +} + +impl RandomTokenMintNotAllowedError { + pub fn new(token_id: Identifier, reason: String) -> Self { + Self { token_id, reason } + } + + pub fn token_id(&self) -> Identifier { + self.token_id + } + + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl From for ConsensusError { + fn from(err: RandomTokenMintNotAllowedError) -> Self { + Self::BasicError(BasicError::RandomTokenMintNotAllowedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 5dbda085705..8952e362d6b 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -192,6 +192,7 @@ impl ErrorWithCode for BasicError { Self::InvalidTokenNoteTooBigError(_) => 10459, Self::TokenNoteOnlyAllowedWhenProposerError(_) => 10460, Self::TokenPricingScheduleEmptyError(_) => 10461, + Self::RandomTokenMintNotAllowedError(_) => 10462, // Identity Errors: 10500-10599 Self::DuplicatedIdentityPublicKeyBasicError(_) => 10500, diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 0772907f04e..83072b29b54 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -99,7 +99,9 @@ use crate::state_transition::batch_transition::batched_transition::document_tran use crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef; #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::resolvers::v0::BatchTransitionResolversV0; -use crate::state_transition::batch_transition::{BatchTransition, BatchTransitionSignable}; +use crate::state_transition::batch_transition::{ + BatchTransition, BatchTransitionSignable, TokenMintTransition, TokenTransferTransition, +}; use crate::state_transition::contract_fee_claim_transition::{ ContractFeeClaimTransition, ContractFeeClaimTransitionSignable, }; @@ -1131,6 +1133,22 @@ fn batch_carries_a_version_2_document_base(batch_transition: &BatchTransition) - }) } +/// Whether `batch_transition` carries a token transfer or a token mint of format version 1, the +/// format that can move a random amount. +fn batch_carries_a_version_1_token_transfer_or_mint(batch_transition: &BatchTransition) -> bool { + batch_transition + .transitions_iter() + .any(|transition| match transition { + BatchedTransitionRef::Token(TokenTransition::Transfer(transfer)) => { + matches!(transfer, TokenTransferTransition::V1(_)) + } + BatchedTransitionRef::Token(TokenTransition::Mint(mint)) => { + matches!(mint, TokenMintTransition::V1(_)) + } + BatchedTransitionRef::Token(_) | BatchedTransitionRef::Document(_) => false, + }) +} + impl StateTransition { /// Decodes a transition from bytes that arrived from outside this node and refuses one /// whose version is not active at `platform_version`. Bytes left over after the transition @@ -1220,6 +1238,14 @@ impl StateTransition { active_version_range_for_contract(data_contract_update_transition.data_contract()) } StateTransition::Batch(batch_transition) => { + // Version 1 of the token transfer and of the token mint (a random amount) + // exists from protocol version 15. Binaries from before it cannot decode one, so + // an earlier version rejects the batch without charging, exactly as they do. + // Checked first: 15 is the later start, so it holds whatever else the batch + // carries. + if batch_carries_a_version_1_token_transfer_or_mint(batch_transition) { + return 15..=LATEST_VERSION; + } // Version 2 of the document base (the action fee agreement) exists from // protocol version 14. Binaries from before it cannot decode one, so an // earlier version rejects the batch without charging, exactly as they do. @@ -3596,6 +3622,96 @@ mod tests { assert_eq!(recovered, version_2); } + // Version 1 of the token transfer and of the token mint carries a random amount, which + // exists from protocol version 15. Software from before it cannot decode either, so new + // software must refuse the batch while an earlier version is active; version 0 stays + // active everywhere its batch is. + #[test] + fn should_not_activate_a_version_1_token_transfer_or_mint_before_protocol_version_15() { + use crate::serialization::PlatformSerializable; + use crate::state_transition::batch_transition::batched_transition::BatchedTransition; + use crate::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; + use crate::state_transition::batch_transition::token_mint_transition::{ + TokenMintTransitionV0, TokenMintTransitionV1, + }; + use crate::state_transition::batch_transition::token_transfer_transition::{ + TokenTransferTransitionV0, TokenTransferTransitionV1, + }; + use crate::state_transition::batch_transition::BatchTransitionV1; + + let base = TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 1, + token_contract_position: 0, + data_contract_id: Identifier::from([1u8; 32]), + token_id: Identifier::from([2u8; 32]), + using_group_info: None, + }); + let batch = |token_transition: TokenTransition| { + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from([9u8; 32]), + transitions: vec![BatchedTransition::Token(token_transition)], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![0u8; 65]), + })) + }; + let transfer_v0 = TokenTransferTransitionV0 { + base: base.clone(), + amount: 100, + recipient_id: Identifier::from([3u8; 32]), + public_note: None, + shared_encrypted_note: None, + private_encrypted_note: None, + }; + let mint_v0 = TokenMintTransitionV0 { + base, + issued_to_identity_id: Some(Identifier::from([3u8; 32])), + amount: 100, + public_note: None, + }; + + assert_eq!( + batch(TokenTransition::Transfer(TokenTransferTransition::V0( + transfer_v0.clone() + ))) + .active_version_range(), + 9..=LATEST_VERSION + ); + assert_eq!( + batch(TokenTransition::Mint(TokenMintTransition::V0( + mint_v0.clone() + ))) + .active_version_range(), + 9..=LATEST_VERSION + ); + + let random_transfer = batch(TokenTransition::Transfer(TokenTransferTransition::V1( + TokenTransferTransitionV1::from_v0(transfer_v0, Some(10)), + ))); + let random_mint = batch(TokenTransition::Mint(TokenMintTransition::V1( + TokenMintTransitionV1::from_v0(mint_v0, Some(10)), + ))); + let version_14 = PlatformVersion::get(14).expect("platform version 14 exists"); + for random in [random_transfer, random_mint] { + assert_eq!(random.active_version_range(), 15..=LATEST_VERSION); + let bytes = + PlatformSerializable::serialize_to_bytes(&random).expect("serialize succeeds"); + assert!(matches!( + StateTransition::deserialize_from_bytes_untrusted_in_version(&bytes, version_14), + Err(ProtocolError::StateTransitionError( + StateTransitionIsNotActiveError { .. } + )) + )); + let recovered = StateTransition::deserialize_from_bytes_untrusted_in_version( + &bytes, + PlatformVersion::latest(), + ) + .expect("expected protocol version 15 to decode a version 1 token transition"); + assert_eq!(recovered, random); + } + } + fn sample_batch_st_empty() -> StateTransition { StateTransition::Batch(BatchTransition::V0(BatchTransitionV0 { owner_id: Identifier::from([1u8; 32]), diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/mod.rs index 7c6520ad18d..d47279c4b09 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/mod.rs @@ -1,5 +1,6 @@ pub mod v0; mod v0_methods; +pub mod v1; pub mod validate_structure; #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -11,6 +12,7 @@ use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; pub use v0::TokenMintTransitionV0; +pub use v1::TokenMintTransitionV1; #[derive(Debug, Clone, Encode, Decode, PartialEq, Display, From, DecodeUntrusted)] #[cfg_attr( @@ -22,6 +24,10 @@ pub enum TokenMintTransition { #[display("V0({})", "_0")] #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] V0(TokenMintTransitionV0), + /// Can issue a random amount (protocol version 15) + #[display("V1({})", "_0")] + #[cfg_attr(feature = "serde-conversion", serde(rename = "1"))] + V1(TokenMintTransitionV1), } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -32,7 +38,8 @@ impl ValueConvertible for TokenMintTransition {} impl Default for TokenMintTransition { fn default() -> Self { - TokenMintTransition::V0(TokenMintTransitionV0::default()) // since only v0 + // Version 0, which every protocol version accepts + TokenMintTransition::V0(TokenMintTransitionV0::default()) } } @@ -121,4 +128,69 @@ pub(crate) mod json_convertible_tests { let recovered = TokenMintTransition::from_object(value).expect("from_object"); assert_eq!(original, recovered); } + + /// A version 1 mint issuing a random amount from 1_000 to 5_000. + pub(crate) fn fixture_v1() -> TokenMintTransition { + TokenMintTransition::V1(TokenMintTransitionV1 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 13, + token_contract_position: 2, + data_contract_id: Identifier::new([0xa1; 32]), + token_id: Identifier::new([0xb2; 32]), + using_group_info: None, + }), + issued_to_identity_id: Some(Identifier::new([0xc3; 32])), + amount: 5_000, + public_note: Some("minting".to_string()), + random_minimum_amount: Some(1_000), + }) + } + + #[test] + fn should_round_trip_a_version_1_mint_through_json() { + use crate::serialization::JsonConvertible; + let original = fixture_v1(); + let json = original.to_json().expect("to_json"); + assert_eq!( + json, + json!({ + "$formatVersion": "1", + "$baseFormatVersion": "0", + "$identity-contract-nonce": 13, + "$tokenContractPosition": 2, + "$dataContractId": Identifier::new([0xa1; 32]), + "$tokenId": Identifier::new([0xb2; 32]), + "issuedToIdentityId": Identifier::new([0xc3; 32]), + "amount": 5_000, + "publicNote": "minting", + "$randomMinimumAmount": 1_000, + }) + ); + let recovered = TokenMintTransition::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn should_round_trip_a_version_1_mint_through_a_value() { + use crate::serialization::ValueConvertible; + let original = fixture_v1(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "$formatVersion": "1", + "$baseFormatVersion": "0", + "$identity-contract-nonce": 13u64, + "$tokenContractPosition": 2u16, + "$dataContractId": Identifier::new([0xa1; 32]), + "$tokenId": Identifier::new([0xb2; 32]), + "issuedToIdentityId": Identifier::new([0xc3; 32]), + "amount": 5_000u64, + "publicNote": "minting", + "$randomMinimumAmount": 1_000u64, + }) + ); + let recovered = TokenMintTransition::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0/v0_methods.rs index 7a307a574bd..bfb601fbbf2 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0/v0_methods.rs @@ -33,6 +33,18 @@ pub trait TokenMintTransitionV0Methods: fn set_amount(&mut self, amount: u64); + /// The least the mint issues when its amount is random (format version 1, protocol version + /// 15): the recipient receives an amount drawn uniformly from it to `amount`, both included. + /// `None` for a mint of exactly `amount`, which every version 0 mint is. + fn random_minimum_amount(&self) -> Option; + + /// Sets the least the mint issues when its amount is random. On a + /// [`TokenMintTransition`] the transition takes the format that holds the value: `Some` + /// turns a version 0 mint into a version 1 one, and `None` turns a version 1 mint back into + /// version 0, the format every protocol version accepts. A bare `TokenMintTransitionV0` + /// holds no random amount, so it ignores the call. + fn set_random_minimum_amount(&mut self, random_minimum_amount: Option); + /// Returns the `public_note` field of the `TokenMintTransitionV0`. fn public_note(&self) -> Option<&String>; @@ -62,6 +74,14 @@ impl TokenMintTransitionV0Methods for TokenMintTransitionV0 { self.amount = amount; } + fn random_minimum_amount(&self) -> Option { + None + } + + fn set_random_minimum_amount(&mut self, _random_minimum_amount: Option) { + // The version 0 format holds no random amount; the enum converts to version 1 + } + fn public_note(&self) -> Option<&String> { self.public_note.as_ref() } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0_methods.rs index 2fb4193eedf..c39879fafae 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0_methods.rs @@ -7,7 +7,7 @@ use crate::ProtocolError; use crate::state_transition::batch_transition::batched_transition::multi_party_action::AllowedAsMultiPartyAction; use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; -use crate::state_transition::batch_transition::token_mint_transition::TokenMintTransition; +use crate::state_transition::batch_transition::token_mint_transition::{TokenMintTransition, TokenMintTransitionV1}; use crate::state_transition::batch_transition::token_mint_transition::v0::v0_methods::TokenMintTransitionV0Methods; use crate::util::hash::hash_double; @@ -15,18 +15,21 @@ impl TokenBaseTransitionAccessors for TokenMintTransition { fn base(&self) -> &TokenBaseTransition { match self { TokenMintTransition::V0(v0) => &v0.base, + TokenMintTransition::V1(v1) => &v1.base, } } fn base_mut(&mut self) -> &mut TokenBaseTransition { match self { TokenMintTransition::V0(v0) => &mut v0.base, + TokenMintTransition::V1(v1) => &mut v1.base, } } fn set_base(&mut self, base: TokenBaseTransition) { match self { TokenMintTransition::V0(v0) => v0.base = base, + TokenMintTransition::V1(v1) => v1.base = base, } } } @@ -35,36 +38,66 @@ impl TokenMintTransitionV0Methods for TokenMintTransition { fn amount(&self) -> u64 { match self { TokenMintTransition::V0(v0) => v0.amount(), + TokenMintTransition::V1(v1) => v1.amount(), } } fn set_amount(&mut self, amount: u64) { match self { TokenMintTransition::V0(v0) => v0.set_amount(amount), + TokenMintTransition::V1(v1) => v1.set_amount(amount), } } + fn random_minimum_amount(&self) -> Option { + match self { + TokenMintTransition::V0(_) => None, + TokenMintTransition::V1(v1) => v1.random_minimum_amount, + } + } + + fn set_random_minimum_amount(&mut self, random_minimum_amount: Option) { + let current = std::mem::take(self); + *self = match (current, random_minimum_amount) { + (TokenMintTransition::V0(v0), None) => TokenMintTransition::V0(v0), + (TokenMintTransition::V0(v0), Some(minimum)) => { + TokenMintTransition::V1(TokenMintTransitionV1::from_v0(v0, Some(minimum))) + } + (TokenMintTransition::V1(mut v1), Some(minimum)) => { + v1.random_minimum_amount = Some(minimum); + TokenMintTransition::V1(v1) + } + (TokenMintTransition::V1(v1), None) => { + TokenMintTransition::V0(v1.into_v0_and_random_minimum_amount().0) + } + }; + } + fn public_note(&self) -> Option<&String> { match self { TokenMintTransition::V0(v0) => v0.public_note(), + TokenMintTransition::V1(v1) => v1.public_note(), } } fn public_note_owned(self) -> Option { match self { TokenMintTransition::V0(v0) => v0.public_note_owned(), + TokenMintTransition::V1(v1) => v1.public_note_owned(), } } fn set_public_note(&mut self, public_note: Option) { match self { TokenMintTransition::V0(v0) => v0.set_public_note(public_note), + TokenMintTransition::V1(v1) => v1.set_public_note(public_note), } } fn issued_to_identity_id(&self) -> Option { match self { TokenMintTransition::V0(v0) => v0.issued_to_identity_id(), + TokenMintTransition::V1(v1) => v1.issued_to_identity_id(), } } @@ -74,12 +107,14 @@ impl TokenMintTransitionV0Methods for TokenMintTransition { ) -> Result { match self { TokenMintTransition::V0(v0) => v0.recipient_id(token_configuration), + TokenMintTransition::V1(v1) => v1.recipient_id(token_configuration), } } fn set_issued_to_identity_id(&mut self, issued_to_identity_id: Option) { match self { TokenMintTransition::V0(v0) => v0.set_issued_to_identity_id(issued_to_identity_id), + TokenMintTransition::V1(v1) => v1.set_issued_to_identity_id(issued_to_identity_id), } } } @@ -92,6 +127,7 @@ impl AllowedAsMultiPartyAction for TokenMintTransition { ) -> Result { match self { TokenMintTransition::V0(v0) => v0.calculate_action_id(owner_id, platform_version), + TokenMintTransition::V1(v1) => v1.calculate_action_id(owner_id, platform_version), } } } @@ -112,3 +148,60 @@ impl TokenMintTransition { hash_double(bytes).into() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + use crate::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV0; + + fn mint_v0() -> TokenMintTransition { + TokenMintTransition::V0(TokenMintTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 4, + token_contract_position: 1, + data_contract_id: Identifier::new([1; 32]), + token_id: Identifier::new([2; 32]), + using_group_info: None, + }), + issued_to_identity_id: Some(Identifier::new([3; 32])), + amount: 500, + public_note: Some("note".to_string()), + }) + } + + #[test] + fn should_take_the_format_that_holds_the_random_minimum_amount() { + let original = mint_v0(); + let mut mint = original.clone(); + assert_eq!(mint.random_minimum_amount(), None); + + mint.set_random_minimum_amount(None); + assert_eq!(mint, original, "a version 0 mint stays as it is"); + + mint.set_random_minimum_amount(Some(10)); + assert!(matches!(mint, TokenMintTransition::V1(_))); + assert_eq!(mint.random_minimum_amount(), Some(10)); + assert_eq!(mint.amount(), 500); + assert_eq!( + mint.issued_to_identity_id(), + original.issued_to_identity_id() + ); + assert_eq!(mint.public_note(), original.public_note()); + assert_eq!(mint.base(), original.base()); + assert_eq!( + mint.calculate_action_id(Identifier::new([9; 32]), PlatformVersion::latest()) + .expect("an action id"), + original + .calculate_action_id(Identifier::new([9; 32]), PlatformVersion::latest()) + .expect("an action id"), + "the random minimum amount takes no part in the action id" + ); + + mint.set_random_minimum_amount(None); + assert_eq!( + mint, original, + "without a random amount the mint is version 0 again" + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/mod.rs new file mode 100644 index 00000000000..aaca74e4d5c --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/mod.rs @@ -0,0 +1,115 @@ +pub mod v1_methods; + +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_mint_transition::v0::TokenMintTransitionV0; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::string_encoding::Encoding; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// A token mint that can issue a random amount (protocol version 15): every field of +/// [`TokenMintTransitionV0`], and `random_minimum_amount`. +/// +/// `amount` is the most the mint issues, and what the token's maximum supply must have room +/// for: the block that carries the mint adds it to the total supply. With +/// `random_minimum_amount` set, the amount the recipient receives is drawn uniformly from +/// `random_minimum_amount` to `amount`, both included, from the block's random beacon once the +/// block's transitions are accepted, and the rest is taken off the total supply again. Without +/// it the mint issues `amount`, as version 0 does. +/// +/// A random mint names its recipient (`issued_to_identity_id`) and is not a group action: +/// structure validation refuses either, so the payout depends on nothing read after the block +/// executes it, and group members never weigh an amount nobody knows. +#[derive(Debug, Clone, Default, Encode, Decode, PartialEq, DecodeUntrusted)] +// Auto-injects `json_safe_u64` on `amount` and `json_safe_option_u64` on +// `random_minimum_amount`. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct TokenMintTransitionV1 { + /// Document Base Transition + #[cfg_attr(feature = "serde-conversion", serde(flatten))] + pub base: TokenBaseTransition, + #[cfg_attr(feature = "serde-conversion", serde(rename = "issuedToIdentityId"))] + /// Who should we issue the token to? If this is not set then we issue to the identity set in + /// contract settings. If such an operation is allowed. A random mint must set it. + pub issued_to_identity_id: Option, + + /// The most the mint issues, added to the total supply when the block executes it + pub amount: u64, + /// The public note + #[cfg_attr(feature = "serde-conversion", serde(rename = "publicNote"))] + pub public_note: Option, + /// The least the mint issues, when the amount is random: the recipient receives an amount + /// drawn uniformly from this to `amount`. Structure validation requires it to be at least 1 + /// and below `amount`. + #[cfg_attr(feature = "serde-conversion", serde(rename = "$randomMinimumAmount"))] + pub random_minimum_amount: Option, +} + +impl TokenMintTransitionV1 { + /// `v0` with `random_minimum_amount`. + pub fn from_v0(v0: TokenMintTransitionV0, random_minimum_amount: Option) -> Self { + let TokenMintTransitionV0 { + base, + issued_to_identity_id, + amount, + public_note, + } = v0; + Self { + base, + issued_to_identity_id, + amount, + public_note, + random_minimum_amount, + } + } + + /// The version 0 fields, and the random minimum amount version 0 cannot hold. + pub fn into_v0_and_random_minimum_amount(self) -> (TokenMintTransitionV0, Option) { + let TokenMintTransitionV1 { + base, + issued_to_identity_id, + amount, + public_note, + random_minimum_amount, + } = self; + ( + TokenMintTransitionV0 { + base, + issued_to_identity_id, + amount, + public_note, + }, + random_minimum_amount, + ) + } +} + +impl fmt::Display for TokenMintTransitionV1 { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let recipient = self + .issued_to_identity_id + .as_ref() + .map_or("(Identity Set By Contract)".to_string(), |id| { + id.to_string(Encoding::Base58) + }); + match self.random_minimum_amount { + Some(minimum) => write!( + f, + "Base: {}, Amount: {} to {}, To: {}", + self.base, minimum, self.amount, recipient + ), + None => write!( + f, + "Base: {}, Amount: {}, To: {}", + self.base, self.amount, recipient + ), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/v1_methods.rs new file mode 100644 index 00000000000..8291b5c4a46 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/v1_methods.rs @@ -0,0 +1,101 @@ +use platform_value::Identifier; +use platform_version::version::PlatformVersion; +use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use crate::data_contract::associated_token::token_configuration::TokenConfiguration; +use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; +use crate::ProtocolError; +use crate::state_transition::batch_transition::batched_transition::multi_party_action::AllowedAsMultiPartyAction; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use crate::state_transition::batch_transition::token_mint_transition::v0::v0_methods::TokenMintTransitionV0Methods; +use crate::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV1; +use crate::state_transition::batch_transition::TokenMintTransition; +use crate::tokens::errors::TokenError; + +impl TokenBaseTransitionAccessors for TokenMintTransitionV1 { + fn base(&self) -> &TokenBaseTransition { + &self.base + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + &mut self.base + } + + fn set_base(&mut self, base: TokenBaseTransition) { + self.base = base; + } +} + +impl TokenMintTransitionV0Methods for TokenMintTransitionV1 { + fn amount(&self) -> u64 { + self.amount + } + + fn set_amount(&mut self, amount: u64) { + self.amount = amount; + } + + fn random_minimum_amount(&self) -> Option { + self.random_minimum_amount + } + + fn set_random_minimum_amount(&mut self, random_minimum_amount: Option) { + self.random_minimum_amount = random_minimum_amount; + } + + fn public_note(&self) -> Option<&String> { + self.public_note.as_ref() + } + + fn public_note_owned(self) -> Option { + self.public_note + } + + fn set_public_note(&mut self, public_note: Option) { + self.public_note = public_note; + } + + fn issued_to_identity_id(&self) -> Option { + self.issued_to_identity_id + } + + fn recipient_id( + &self, + token_configuration: &TokenConfiguration, + ) -> Result { + match self.issued_to_identity_id() { + None => token_configuration + .distribution_rules() + .new_tokens_destination_identity() + .copied() + .ok_or(ProtocolError::Token( + TokenError::TokenNoMintingRecipient.into(), + )), + Some(recipient) => Ok(recipient), + } + } + + fn set_issued_to_identity_id(&mut self, issued_to_identity_id: Option) { + self.issued_to_identity_id = issued_to_identity_id; + } +} + +impl AllowedAsMultiPartyAction for TokenMintTransitionV1 { + /// The same id a version 0 mint of `amount` has. A random mint is never a group action + /// (structure validation refuses one), so the random minimum amount takes no part. + fn calculate_action_id( + &self, + owner_id: Identifier, + _platform_version: &PlatformVersion, + ) -> Result { + let TokenMintTransitionV1 { base, amount, .. } = self; + + Ok(TokenMintTransition::calculate_action_id_with_fields( + base.token_id().as_bytes(), + owner_id.as_bytes(), + base.identity_contract_nonce(), + *amount, + )) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/validate_structure/v0/mod.rs index 4674fad772f..08a15c80904 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/validate_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/validate_structure/v0/mod.rs @@ -1,4 +1,4 @@ -use crate::consensus::basic::token::{InvalidTokenAmountError, InvalidTokenNoteTooBigError, TokenNoteOnlyAllowedWhenProposerError}; +use crate::consensus::basic::token::{InvalidTokenAmountError, InvalidTokenNoteTooBigError, RandomTokenMintNotAllowedError, TokenNoteOnlyAllowedWhenProposerError}; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::associated_token::token_perpetual_distribution::distribution_function::MAX_DISTRIBUTION_PARAM; @@ -23,6 +23,42 @@ impl TokenMintTransitionActionStructureValidationV0 for TokenMintTransition { )); } + // Only a version 1 mint carries a random minimum amount, and no protocol version before + // 15 decodes one (`StateTransition::active_version_range`), so these checks are inert + // at every earlier version that selects this generation. + if let Some(minimum) = self.random_minimum_amount() { + if minimum == 0 || minimum >= self.amount() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::BasicError(BasicError::InvalidTokenAmountError( + InvalidTokenAmountError::new(self.amount().saturating_sub(1), minimum), + )), + )); + } + // Group members agree on an amount, which a random mint does not have + if self.base().using_group_info().is_some() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::BasicError(BasicError::RandomTokenMintNotAllowedError( + RandomTokenMintNotAllowedError::new( + self.base().token_id(), + "a group action cannot mint a random amount".to_string(), + ), + )), + )); + } + // The payout lands after the block executes the mint, so it must not depend on the + // token's configuration read then + if self.issued_to_identity_id().is_none() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::BasicError(BasicError::RandomTokenMintNotAllowedError( + RandomTokenMintNotAllowedError::new( + self.base().token_id(), + "a random mint must name its recipient".to_string(), + ), + )), + )); + } + } + if let Some(public_note) = self.public_note() { if public_note.len() > MAX_TOKEN_NOTE_LEN { return Ok(SimpleConsensusValidationResult::new_with_error( @@ -57,7 +93,9 @@ mod tests { use crate::group::GroupStateTransitionInfo; use crate::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; - use crate::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV0; + use crate::state_transition::batch_transition::token_mint_transition::{ + TokenMintTransitionV0, TokenMintTransitionV1, + }; use platform_value::Identifier; fn make_base() -> TokenBaseTransition { @@ -211,4 +249,85 @@ mod tests { let result = transition.validate_structure_v0().unwrap(); assert!(result.is_valid(), "expected no errors: {:?}", result.errors); } + + fn random_mint( + base: TokenBaseTransition, + issued_to_identity_id: Option, + amount: u64, + random_minimum_amount: Option, + ) -> TokenMintTransition { + TokenMintTransition::V1(TokenMintTransitionV1 { + base, + issued_to_identity_id, + amount, + public_note: None, + random_minimum_amount, + }) + } + + fn recipient() -> Option { + Some(Identifier::new([4u8; 32])) + } + + #[test] + fn should_accept_a_random_mint_from_one_to_below_the_amount() { + for minimum in [1, 250, 499] { + let result = random_mint(make_base(), recipient(), 500, Some(minimum)) + .validate_structure_v0() + .unwrap(); + assert!(result.is_valid(), "{minimum}: {:?}", result.errors); + } + // A version 1 mint without a random amount is a plain mint + let result = random_mint(make_base(), None, 500, None) + .validate_structure_v0() + .unwrap(); + assert!(result.is_valid(), "expected no errors: {:?}", result.errors); + } + + #[test] + fn should_refuse_a_random_mint_minimum_of_zero_or_not_below_the_amount() { + for minimum in [0, 500, 501] { + let result = random_mint(make_base(), recipient(), 500, Some(minimum)) + .validate_structure_v0() + .unwrap(); + assert_eq!(result.errors.len(), 1); + assert!(matches!( + &result.errors[0], + ConsensusError::BasicError(BasicError::InvalidTokenAmountError(e)) + if e.token_amount() == minimum + )); + } + } + + #[test] + fn should_refuse_a_random_mint_as_a_group_action() { + for is_proposer in [true, false] { + let result = random_mint(make_base_with_group(is_proposer), recipient(), 500, Some(1)) + .validate_structure_v0() + .unwrap(); + assert_eq!(result.errors.len(), 1); + assert!(matches!( + &result.errors[0], + ConsensusError::BasicError(BasicError::RandomTokenMintNotAllowedError(e)) + if e.token_id() == Identifier::new([1u8; 32]) + )); + } + // A group mint of a fixed amount stays allowed + let result = random_mint(make_base_with_group(true), recipient(), 500, None) + .validate_structure_v0() + .unwrap(); + assert!(result.is_valid(), "expected no errors: {:?}", result.errors); + } + + #[test] + fn should_refuse_a_random_mint_without_a_recipient() { + let result = random_mint(make_base(), None, 500, Some(1)) + .validate_structure_v0() + .unwrap(); + assert_eq!(result.errors.len(), 1); + assert!(matches!( + &result.errors[0], + ConsensusError::BasicError(BasicError::RandomTokenMintNotAllowedError(_)) + )); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/mod.rs index d5d66023cb3..d6bfe413f9e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/mod.rs @@ -1,5 +1,6 @@ pub mod v0; pub mod v0_methods; +pub mod v1; pub mod validate_structure; #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -11,6 +12,7 @@ use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; pub use v0::*; +pub use v1::TokenTransferTransitionV1; #[derive(Debug, Clone, Encode, Decode, PartialEq, Display, From, DecodeUntrusted)] #[cfg_attr( @@ -22,6 +24,10 @@ pub enum TokenTransferTransition { #[display("V0({})", "_0")] #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] V0(TokenTransferTransitionV0), + /// Can move a random amount (protocol version 15) + #[display("V1({})", "_0")] + #[cfg_attr(feature = "serde-conversion", serde(rename = "1"))] + V1(TokenTransferTransitionV1), } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -119,4 +125,75 @@ pub(crate) mod json_convertible_tests { let recovered = TokenTransferTransition::from_object(value).expect("from_object"); assert_eq!(original, recovered); } + + /// A version 1 transfer moving a random amount from 40 to 250. + pub(crate) fn fixture_v1() -> TokenTransferTransition { + TokenTransferTransition::V1(TokenTransferTransitionV1 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 14, + token_contract_position: 3, + data_contract_id: Identifier::new([0xa1; 32]), + token_id: Identifier::new([0xb2; 32]), + using_group_info: None, + }), + amount: 250, + recipient_id: Identifier::new([0xc3; 32]), + public_note: Some("transfer note".to_string()), + shared_encrypted_note: None, + private_encrypted_note: None, + random_minimum_amount: Some(40), + }) + } + + #[test] + fn should_round_trip_a_version_1_transfer_through_json() { + use crate::serialization::JsonConvertible; + let original = fixture_v1(); + let json = original.to_json().expect("to_json"); + assert_eq!( + json, + json!({ + "$formatVersion": "1", + "$baseFormatVersion": "0", + "$identity-contract-nonce": 14, + "$tokenContractPosition": 3, + "$dataContractId": Identifier::new([0xa1; 32]), + "$tokenId": Identifier::new([0xb2; 32]), + "$amount": 250, + "recipientId": Identifier::new([0xc3; 32]), + "publicNote": "transfer note", + "sharedEncryptedNote": null, + "privateEncryptedNote": null, + "$randomMinimumAmount": 40, + }) + ); + let recovered = TokenTransferTransition::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn should_round_trip_a_version_1_transfer_through_a_value() { + use crate::serialization::ValueConvertible; + let original = fixture_v1(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "$formatVersion": "1", + "$baseFormatVersion": "0", + "$identity-contract-nonce": 14u64, + "$tokenContractPosition": 3u16, + "$dataContractId": Identifier::new([0xa1; 32]), + "$tokenId": Identifier::new([0xb2; 32]), + "$amount": 250u64, + "recipientId": Identifier::new([0xc3; 32]), + "publicNote": "transfer note", + "sharedEncryptedNote": null, + "privateEncryptedNote": null, + "$randomMinimumAmount": 40u64, + }) + ); + let recovered = TokenTransferTransition::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0/v0_methods.rs index d2846d5fda1..c81da529953 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0/v0_methods.rs @@ -26,6 +26,19 @@ pub trait TokenTransferTransitionV0Methods: TokenBaseTransitionAccessors { /// Sets the value of the `amount` field in the `TokenTransferTransitionV0`. fn set_amount(&mut self, amount: u64); + /// The least the transfer moves when its amount is random (format version 1, protocol + /// version 15): the recipient receives an amount drawn uniformly from it to `amount`, both + /// included. `None` for a transfer of exactly `amount`, which every version 0 transfer is. + fn random_minimum_amount(&self) -> Option; + + /// Sets the least the transfer moves when its amount is random. On a + /// [`TokenTransferTransition`](crate::state_transition::batch_transition::TokenTransferTransition) + /// the transition takes the format that holds the value: `Some` turns a version 0 transfer + /// into a version 1 one, and `None` turns a version 1 transfer back into version 0, the + /// format every protocol version accepts. A bare `TokenTransferTransitionV0` holds no random + /// amount, so it ignores the call. + fn set_random_minimum_amount(&mut self, random_minimum_amount: Option); + /// Returns the `recipient_owner_id` field of the `TokenTransferTransitionV0`. fn recipient_id(&self) -> Identifier; @@ -108,6 +121,14 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransitionV0 { self.amount = amount; } + fn random_minimum_amount(&self) -> Option { + None + } + + fn set_random_minimum_amount(&mut self, _random_minimum_amount: Option) { + // The version 0 format holds no random amount; the enum converts to version 1 + } + fn recipient_id(&self) -> Identifier { self.recipient_id } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0_methods.rs index aff9670182f..50c2e43bc10 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0_methods.rs @@ -1,6 +1,7 @@ use platform_value::Identifier; use crate::prelude::{DerivationEncryptionKeyIndex, RecipientKeyIndex, RootEncryptionKeyIndex, SenderKeyIndex}; use crate::state_transition::batch_transition::batched_transition::token_transfer_transition::v0::v0_methods::TokenTransferTransitionV0Methods; +use crate::state_transition::batch_transition::batched_transition::token_transfer_transition::{TokenTransferTransitionV0, TokenTransferTransitionV1}; use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; use crate::state_transition::batch_transition::TokenTransferTransition; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; @@ -10,18 +11,21 @@ impl TokenBaseTransitionAccessors for TokenTransferTransition { fn base(&self) -> &TokenBaseTransition { match self { TokenTransferTransition::V0(v0) => &v0.base, + TokenTransferTransition::V1(v1) => &v1.base, } } fn base_mut(&mut self) -> &mut TokenBaseTransition { match self { TokenTransferTransition::V0(v0) => &mut v0.base, + TokenTransferTransition::V1(v1) => &mut v1.base, } } fn set_base(&mut self, base: TokenBaseTransition) { match self { TokenTransferTransition::V0(v0) => v0.base = base, + TokenTransferTransition::V1(v1) => v1.base = base, } } } @@ -30,30 +34,62 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransition { fn amount(&self) -> u64 { match self { TokenTransferTransition::V0(v0) => v0.amount, + TokenTransferTransition::V1(v1) => v1.amount, } } fn set_amount(&mut self, amount: u64) { match self { TokenTransferTransition::V0(v0) => v0.amount = amount, + TokenTransferTransition::V1(v1) => v1.amount = amount, } } + fn random_minimum_amount(&self) -> Option { + match self { + TokenTransferTransition::V0(_) => None, + TokenTransferTransition::V1(v1) => v1.random_minimum_amount, + } + } + + fn set_random_minimum_amount(&mut self, random_minimum_amount: Option) { + let current = std::mem::replace( + self, + TokenTransferTransition::V0(TokenTransferTransitionV0::default()), + ); + *self = match (current, random_minimum_amount) { + (TokenTransferTransition::V0(v0), None) => TokenTransferTransition::V0(v0), + (TokenTransferTransition::V0(v0), Some(minimum)) => { + TokenTransferTransition::V1(TokenTransferTransitionV1::from_v0(v0, Some(minimum))) + } + (TokenTransferTransition::V1(mut v1), Some(minimum)) => { + v1.random_minimum_amount = Some(minimum); + TokenTransferTransition::V1(v1) + } + (TokenTransferTransition::V1(v1), None) => { + TokenTransferTransition::V0(v1.into_v0_and_random_minimum_amount().0) + } + }; + } + fn recipient_id(&self) -> Identifier { match self { TokenTransferTransition::V0(v0) => v0.recipient_id, + TokenTransferTransition::V1(v1) => v1.recipient_id, } } fn recipient_id_ref(&self) -> &Identifier { match self { TokenTransferTransition::V0(v0) => &v0.recipient_id, + TokenTransferTransition::V1(v1) => &v1.recipient_id, } } fn set_recipient_id(&mut self, recipient_id: Identifier) { match self { TokenTransferTransition::V0(v0) => v0.recipient_id = recipient_id, + TokenTransferTransition::V1(v1) => v1.recipient_id = recipient_id, } } @@ -61,36 +97,42 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransition { fn public_note(&self) -> Option<&String> { match self { TokenTransferTransition::V0(v0) => v0.public_note.as_ref(), + TokenTransferTransition::V1(v1) => v1.public_note.as_ref(), } } fn public_note_owned(self) -> Option { match self { TokenTransferTransition::V0(v0) => v0.public_note, + TokenTransferTransition::V1(v1) => v1.public_note, } } fn set_public_note(&mut self, public_note: Option) { match self { TokenTransferTransition::V0(v0) => v0.public_note = public_note, + TokenTransferTransition::V1(v1) => v1.public_note = public_note, } } fn shared_encrypted_note(&self) -> Option<&SharedEncryptedNote> { match self { TokenTransferTransition::V0(v0) => v0.shared_encrypted_note.as_ref(), + TokenTransferTransition::V1(v1) => v1.shared_encrypted_note.as_ref(), } } fn shared_encrypted_note_owned(self) -> Option { match self { TokenTransferTransition::V0(v0) => v0.shared_encrypted_note, + TokenTransferTransition::V1(v1) => v1.shared_encrypted_note, } } fn set_shared_encrypted_note(&mut self, shared_encrypted_note: Option) { match self { TokenTransferTransition::V0(v0) => v0.shared_encrypted_note = shared_encrypted_note, + TokenTransferTransition::V1(v1) => v1.shared_encrypted_note = shared_encrypted_note, } } @@ -103,6 +145,7 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransition { )> { match self { TokenTransferTransition::V0(v0) => v0.private_encrypted_note.as_ref(), + TokenTransferTransition::V1(v1) => v1.private_encrypted_note.as_ref(), } } @@ -115,12 +158,14 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransition { )> { match self { TokenTransferTransition::V0(v0) => v0.private_encrypted_note, + TokenTransferTransition::V1(v1) => v1.private_encrypted_note, } } fn set_private_encrypted_note(&mut self, private_encrypted_note: Option) { match self { TokenTransferTransition::V0(v0) => v0.private_encrypted_note = private_encrypted_note, + TokenTransferTransition::V1(v1) => v1.private_encrypted_note = private_encrypted_note, } } @@ -138,6 +183,11 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransition { v0.shared_encrypted_note, v0.private_encrypted_note, ), + TokenTransferTransition::V1(v1) => ( + v1.public_note, + v1.shared_encrypted_note, + v1.private_encrypted_note, + ), } } @@ -158,6 +208,61 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransition { v0.shared_encrypted_note.clone(), v0.private_encrypted_note.clone(), ), + TokenTransferTransition::V1(v1) => ( + v1.public_note.clone(), + v1.shared_encrypted_note.clone(), + v1.private_encrypted_note.clone(), + ), } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + + fn transfer_v0() -> TokenTransferTransition { + TokenTransferTransition::V0(TokenTransferTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 4, + token_contract_position: 1, + data_contract_id: Identifier::new([1; 32]), + token_id: Identifier::new([2; 32]), + using_group_info: None, + }), + amount: 500, + recipient_id: Identifier::new([3; 32]), + public_note: Some("note".to_string()), + shared_encrypted_note: None, + private_encrypted_note: Some((1, 2, vec![9])), + }) + } + + #[test] + fn should_take_the_format_that_holds_the_random_minimum_amount() { + let original = transfer_v0(); + let mut transfer = original.clone(); + assert_eq!(transfer.random_minimum_amount(), None); + + transfer.set_random_minimum_amount(None); + assert_eq!(transfer, original, "a version 0 transfer stays as it is"); + + transfer.set_random_minimum_amount(Some(10)); + assert!(matches!(transfer, TokenTransferTransition::V1(_))); + assert_eq!(transfer.random_minimum_amount(), Some(10)); + assert_eq!(transfer.amount(), 500); + assert_eq!(transfer.recipient_id(), Identifier::new([3; 32])); + assert_eq!(transfer.notes(), original.notes()); + assert_eq!(transfer.base(), original.base()); + + transfer.set_random_minimum_amount(Some(20)); + assert_eq!(transfer.random_minimum_amount(), Some(20)); + + transfer.set_random_minimum_amount(None); + assert_eq!( + transfer, original, + "without a random amount the transfer is version 0 again" + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v1/mod.rs new file mode 100644 index 00000000000..e476900b98c --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v1/mod.rs @@ -0,0 +1,130 @@ +use bincode::{Decode, DecodeUntrusted, Encode}; +use std::fmt; + +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; +use crate::tokens::{PrivateEncryptedNote, SharedEncryptedNote}; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// A token transfer that can move a random amount (protocol version 15): every field of +/// [`TokenTransferTransitionV0`], and `random_minimum_amount`. +/// +/// `amount` is the most the transfer moves, and what the sender must hold: the block that +/// carries the transfer takes it from the sender. With `random_minimum_amount` set, the amount +/// that reaches the recipient is drawn uniformly from `random_minimum_amount` to `amount`, both +/// included, from the block's random beacon once the block's transitions are accepted, and the +/// rest goes back to the sender. Without it the transfer moves `amount`, as version 0 does. +#[derive(Debug, Clone, Default, Encode, Decode, PartialEq, DecodeUntrusted)] +// `json_safe_fields` auto-injects `json_safe_u64` on `amount`, `json_safe_option_u64` on +// `random_minimum_amount`, and `json_safe_option_encrypted_note` on the encrypted notes, as for +// version 0. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct TokenTransferTransitionV1 { + #[cfg_attr(feature = "serde-conversion", serde(flatten))] + pub base: TokenBaseTransition, + /// The most the transfer moves, taken from the sender when the block executes it + #[cfg_attr(feature = "serde-conversion", serde(rename = "$amount"))] + pub amount: u64, + #[cfg_attr(feature = "serde-conversion", serde(rename = "recipientId"))] + pub recipient_id: Identifier, + /// The public note + #[cfg_attr(feature = "serde-conversion", serde(rename = "publicNote"))] + pub public_note: Option, + /// An optional shared encrypted note + #[cfg_attr(feature = "serde-conversion", serde(rename = "sharedEncryptedNote"))] + pub shared_encrypted_note: Option, + /// An optional private encrypted note + #[cfg_attr(feature = "serde-conversion", serde(rename = "privateEncryptedNote"))] + pub private_encrypted_note: Option, + /// The least the transfer moves, when the amount is random: the recipient receives an + /// amount drawn uniformly from this to `amount`. Structure validation requires it to be at + /// least 1 and below `amount`. + #[cfg_attr(feature = "serde-conversion", serde(rename = "$randomMinimumAmount"))] + pub random_minimum_amount: Option, +} + +impl fmt::Display for TokenTransferTransitionV1 { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self.random_minimum_amount { + Some(minimum) => write!( + f, + "Base: {}, Amount: {} to {}, Recipient: {}", + self.base, minimum, self.amount, self.recipient_id + ), + None => write!( + f, + "Base: {}, Amount: {}, Recipient: {}", + self.base, self.amount, self.recipient_id + ), + } + } +} + +impl TokenTransferTransitionV1 { + /// `v0` with `random_minimum_amount`. + pub fn from_v0(v0: TokenTransferTransitionV0, random_minimum_amount: Option) -> Self { + let TokenTransferTransitionV0 { + base, + amount, + recipient_id, + public_note, + shared_encrypted_note, + private_encrypted_note, + } = v0; + Self { + base, + amount, + recipient_id, + public_note, + shared_encrypted_note, + private_encrypted_note, + random_minimum_amount, + } + } + + /// The version 0 fields, and the random minimum amount version 0 cannot hold. + pub fn into_v0_and_random_minimum_amount(self) -> (TokenTransferTransitionV0, Option) { + let TokenTransferTransitionV1 { + base, + amount, + recipient_id, + public_note, + shared_encrypted_note, + private_encrypted_note, + random_minimum_amount, + } = self; + ( + TokenTransferTransitionV0 { + base, + amount, + recipient_id, + public_note, + shared_encrypted_note, + private_encrypted_note, + }, + random_minimum_amount, + ) + } +} + +impl TokenBaseTransitionAccessors for TokenTransferTransitionV1 { + fn base(&self) -> &TokenBaseTransition { + &self.base + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + &mut self.base + } + + fn set_base(&mut self, base: TokenBaseTransition) { + self.base = base; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/validate_structure/v0/mod.rs index 77d7460aa64..6a0b312b7cc 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/validate_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/validate_structure/v0/mod.rs @@ -30,6 +30,19 @@ impl TokenTransferTransitionActionStructureValidationV0 for TokenTransferTransit )); } + // Only a version 1 transfer carries a random minimum amount, and no protocol version + // before 15 decodes one (`StateTransition::active_version_range`), so this check is + // inert at every earlier version that selects this generation. + if let Some(minimum) = self.random_minimum_amount() { + if minimum == 0 || minimum >= self.amount() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::BasicError(BasicError::InvalidTokenAmountError( + InvalidTokenAmountError::new(self.amount().saturating_sub(1), minimum), + )), + )); + } + } + if self.recipient_id() == owner_id { return Ok(SimpleConsensusValidationResult::new_with_error( ConsensusError::BasicError(BasicError::TokenTransferToOurselfError( @@ -89,7 +102,9 @@ mod tests { use super::*; use crate::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; - use crate::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV0; + use crate::state_transition::batch_transition::token_transfer_transition::{ + TokenTransferTransitionV0, TokenTransferTransitionV1, + }; fn make_base() -> TokenBaseTransition { TokenBaseTransition::V0(TokenBaseTransitionV0 { @@ -268,4 +283,65 @@ mod tests { ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_)) )); } + + fn random_transfer(amount: u64, random_minimum_amount: Option) -> TokenTransferTransition { + TokenTransferTransition::V1(TokenTransferTransitionV1 { + base: make_base(), + amount, + recipient_id: Identifier::new([2u8; 32]), + public_note: None, + shared_encrypted_note: None, + private_encrypted_note: None, + random_minimum_amount, + }) + } + + fn assert_invalid_random_minimum(result: SimpleConsensusValidationResult, minimum: u64) { + assert_eq!(result.errors.len(), 1); + assert!(matches!( + &result.errors[0], + ConsensusError::BasicError(BasicError::InvalidTokenAmountError(e)) + if e.token_amount() == minimum + )); + } + + #[test] + fn should_accept_a_random_amount_from_one_to_below_the_amount() { + let owner_id = Identifier::new([3u8; 32]); + for minimum in [1, 500, 999] { + let result = random_transfer(1000, Some(minimum)) + .validate_structure_v0(owner_id) + .unwrap(); + assert!(result.is_valid(), "{minimum}: {:?}", result.errors); + } + let result = random_transfer(1000, None) + .validate_structure_v0(owner_id) + .unwrap(); + assert!(result.is_valid(), "expected no errors: {:?}", result.errors); + } + + #[test] + fn should_refuse_a_random_minimum_amount_of_zero() { + let owner_id = Identifier::new([3u8; 32]); + let result = random_transfer(1000, Some(0)) + .validate_structure_v0(owner_id) + .unwrap(); + assert_invalid_random_minimum(result, 0); + } + + #[test] + fn should_refuse_a_random_minimum_amount_not_below_the_amount() { + let owner_id = Identifier::new([3u8; 32]); + for minimum in [1000, 1001] { + let result = random_transfer(1000, Some(minimum)) + .validate_structure_v0(owner_id) + .unwrap(); + assert_invalid_random_minimum(result, minimum); + } + // An amount of 1 leaves no minimum to draw from + let result = random_transfer(1, Some(1)) + .validate_structure_v0(owner_id) + .unwrap(); + assert_invalid_random_minimum(result, 1); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs index 502e3c088ca..b679b892342 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs @@ -1,4 +1,3 @@ -#[cfg(feature = "state-transition-signing")] use crate::balances::credits::TokenAmount; #[cfg(feature = "state-transition-signing")] use crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; @@ -73,6 +72,12 @@ pub struct StateTransitionCreationOptions { /// 250 contenders costs. A create that joins no contest ignores it, a decision proposal's /// included: its fund is fixed. pub contest_fund: Option, + /// The least a token transfer or a token mint moves when its amount is random (protocol + /// version 15): the amount is drawn uniformly from this to the amount the transition names, + /// both included, once the block carrying it is accepted. Setting it builds the transfer or + /// the mint in format version 1, which no protocol version before 15 accepts. Every other + /// transition ignores it. + pub random_minimum_amount: Option, } impl StateTransitionCreationOptions { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs index 7ac7a07d18f..3e836df0d44 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs @@ -63,10 +63,14 @@ use crate::state_transition::batch_transition::token_freeze_transition::TokenFre #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV0; #[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::token_mint_transition::v0::v0_methods::TokenMintTransitionV0Methods; +#[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::token_set_price_for_direct_purchase_transition::TokenSetPriceForDirectPurchaseTransitionV0; #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV0; #[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::token_transfer_transition::v0::v0_methods::TokenTransferTransitionV0Methods; +#[cfg(feature = "state-transition-signing")] use crate::shielded::OrchardBundleParams; #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::token_shield_transition::TokenShieldTransitionV0; @@ -125,6 +129,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { amount, public_note, }); + // A random amount builds the mint in format version 1 (protocol version 15) + mint_transition + .set_random_minimum_amount(options.and_then(|options| options.random_minimum_amount)); if let Some(using_group_info_status) = using_group_info { match using_group_info_status { @@ -279,7 +286,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { options: Option, ) -> Result { // Create the transfer transition for batch version 1 - let transfer_transition = TokenTransferTransition::V0(TokenTransferTransitionV0 { + let mut transfer_transition = TokenTransferTransition::V0(TokenTransferTransitionV0 { base: TokenBaseTransition::V0(TokenBaseTransitionV0 { identity_contract_nonce, token_contract_position, @@ -293,6 +300,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { shared_encrypted_note, private_encrypted_note, }); + // A random amount builds the transfer in format version 1 (protocol version 15) + transfer_transition + .set_random_minimum_amount(options.and_then(|options| options.random_minimum_amount)); // Wrap in a batch transition let documents_batch_transition: BatchTransition = BatchTransitionV1 { 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 aa063c35a84..fa543fbd45e 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 @@ -9,6 +9,7 @@ mod freeze; mod mint; mod mint_many; mod non_transferable; +mod random_amount; mod shielded; mod transfer; mod unfreeze; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/random_amount/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/random_amount/mod.rs new file mode 100644 index 00000000000..e645207e794 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/random_amount/mod.rs @@ -0,0 +1,409 @@ +//! End-to-end coverage for random token amounts (protocol version 15): a token transfer or +//! mint naming a random minimum amount reserves the most it moves in the token marketplace +//! escrow when the block executes it, and `Drive::settle_random_token_amounts` pays out the +//! amount drawn from the block's random beacon once the block's transitions are accepted. The +//! token conservation check balances throughout. A client cannot know the amount, so it +//! reads it from the proof of its own transition. + +use super::*; + +mod random_amount_tests { + use super::*; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::balances::credits::TokenAmount; + use dpp::prelude::DataContract; + use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransition; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; + use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; + use dpp::state_transition::batch_transition::{TokenMintTransition, TokenTransferTransition}; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::StateTransition; + use drive::drive::tokens::settle_random_token_amounts::{ + RandomTokenRequest, RandomTokenRequestKind, + }; + use drive::drive::Drive; + use std::collections::BTreeMap; + use std::sync::Arc; + + const BEACON: [u8; 96] = [7; 96]; + const BASE_SUPPLY: TokenAmount = 100_000; + const MINIMUM: TokenAmount = 100; + const MAXIMUM: TokenAmount = 1_000; + const NONCE: u64 = 2; + + fn random_options() -> Option { + Some(StateTransitionCreationOptions { + random_minimum_amount: Some(MINIMUM), + ..Default::default() + }) + } + + /// The token transition of the single-transition batch `transition`. + fn token_transition(transition: &StateTransition) -> &TokenTransition { + let StateTransition::Batch(BatchTransition::V1(batch)) = transition else { + panic!("expected a version 1 batch"); + }; + let [BatchedTransition::Token(token_transition)] = batch.transitions.as_slice() else { + panic!("expected a single token transition"); + }; + token_transition + } + + fn process( + platform: &TempPlatform, + transition: &StateTransition, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + fn balance( + platform: &TempPlatform, + token_id: Identifier, + identity_id: Identifier, + ) -> Option { + platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity_id.to_buffer(), + None, + PlatformVersion::latest(), + ) + .expect("expected to fetch a token balance") + } + + fn supply( + platform: &TempPlatform, + token_id: Identifier, + ) -> Option { + platform + .drive + .fetch_token_total_supply(token_id.to_buffer(), None, PlatformVersion::latest()) + .expect("expected to fetch the total supply") + } + + /// The identity balances, the shielded pools and the token marketplace escrow, which holds + /// the reservations, add up to the total supplies. + fn assert_tokens_balanced(platform: &TempPlatform) { + let total = platform + .drive + .calculate_total_tokens_balance(None, PlatformVersion::latest()) + .expect("expected to sum the token balances"); + assert!( + total.ok().expect("expected the sums to compare"), + "tokens are not balanced: {total}" + ); + } + + /// Proves the committed state `transition` wrote and verifies the proof as the client that + /// sent it does, holding the token's contract: it cannot know the drawn amount. + fn verify_proof( + platform: &TempPlatform, + transition: &StateTransition, + contract: &DataContract, + ) -> StateTransitionProofResult { + let platform_version = PlatformVersion::latest(); + let proof = platform + .drive + .prove_state_transition(transition, None, platform_version) + .expect("expected to prove the state transition") + .into_data() + .expect("expected proof bytes"); + let known_contracts: BTreeMap = + BTreeMap::from([(contract.id(), contract.clone())]); + let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + &proof, + &|id| Ok(known_contracts.get(id).cloned().map(Arc::new)), + platform_version, + ) + .expect("expected the proof to verify"); + outcome.into_result() + } + + /// The amount of the history document a proof shows. + fn proved_amount(result: StateTransitionProofResult) -> TokenAmount { + let StateTransitionProofResult::VerifiedTokenActionWithDocument(document) = result else { + panic!("expected the proof to show the history document, got {result:?}"); + }; + document + .properties() + .get_integer("amount") + .expect("expected the history document to hold an amount") + } + + fn settle(platform: &TempPlatform, request: RandomTokenRequest) { + let settled = platform + .drive + .settle_random_token_amounts( + &BEACON, + &[request], + &BlockInfo::default(), + None, + PlatformVersion::latest(), + ) + .expect("expected the settlement to run"); + assert_eq!(settled, 1); + } + + #[tokio::test] + async fn should_reserve_a_random_transfer_then_pay_out_the_drawn_amount() { + 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(49855); + 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(), + None::, + None, + None, + None, + platform_version, + ); + + let transition = BatchTransition::new_token_transfer_transition( + token_id, + identity.id(), + contract.id(), + 0, + MAXIMUM, + recipient.id(), + None, + None, + None, + &key, + NONCE, + 0, + &signer, + platform_version, + random_options(), + ) + .await + .expect("expected to create the transfer"); + assert!(matches!( + token_transition(&transition), + TokenTransition::Transfer(TokenTransferTransition::V1(_)) + )); + + let result = process(&platform, &transition); + assert_matches!( + &result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "{result:?}" + ); + // The block moves the most the transfer moves into a reservation, and writes the + // recipient's balance at zero, where it waits for the draw + assert_eq!( + balance(&platform, token_id, identity.id()), + Some(BASE_SUPPLY - MAXIMUM) + ); + assert_eq!(balance(&platform, token_id, recipient.id()), Some(0)); + assert_tokens_balanced(&platform); + // The history holds the range's minimum until the draw + assert_eq!( + proved_amount(verify_proof(&platform, &transition, &contract)), + MINIMUM + ); + + settle( + &platform, + RandomTokenRequest { + token_id, + owner_id: identity.id(), + identity_contract_nonce: NONCE, + minimum: MINIMUM, + maximum: MAXIMUM, + kind: RandomTokenRequestKind::Transfer { + sender_id: identity.id(), + recipient_id: recipient.id(), + }, + }, + ); + + let drawn = balance(&platform, token_id, recipient.id()).expect("the recipient is paid"); + assert!((MINIMUM..=MAXIMUM).contains(&drawn)); + assert_eq!( + balance(&platform, token_id, identity.id()), + Some(BASE_SUPPLY - drawn) + ); + assert_eq!(supply(&platform, token_id), Some(BASE_SUPPLY)); + assert_tokens_balanced(&platform); + // The client that sent the transfer reads the drawn amount from the proof of its own + // transition + assert_eq!( + proved_amount(verify_proof(&platform, &transition, &contract)), + drawn + ); + } + + #[tokio::test] + async fn should_reserve_a_random_mint_then_pay_out_the_drawn_amount() { + 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(49856); + 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(), + None::, + None, + None, + None, + platform_version, + ); + + let transition = BatchTransition::new_token_mint_transition( + token_id, + identity.id(), + contract.id(), + 0, + MAXIMUM, + Some(recipient.id()), + None, + None, + &key, + NONCE, + 0, + &signer, + platform_version, + random_options(), + ) + .await + .expect("expected to create the mint"); + assert!(matches!( + token_transition(&transition), + TokenTransition::Mint(TokenMintTransition::V1(_)) + )); + + let result = process(&platform, &transition); + assert_matches!( + &result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "{result:?}" + ); + // The block adds the most the mint issues to the supply and holds it in a reservation, + // and writes the recipient's balance at zero, where it waits for the draw + assert_eq!(supply(&platform, token_id), Some(BASE_SUPPLY + MAXIMUM)); + assert_eq!(balance(&platform, token_id, recipient.id()), Some(0)); + assert_tokens_balanced(&platform); + + settle( + &platform, + RandomTokenRequest { + token_id, + owner_id: identity.id(), + identity_contract_nonce: NONCE, + minimum: MINIMUM, + maximum: MAXIMUM, + kind: RandomTokenRequestKind::Mint { + recipient_id: recipient.id(), + }, + }, + ); + + let drawn = balance(&platform, token_id, recipient.id()).expect("the recipient is paid"); + assert!((MINIMUM..=MAXIMUM).contains(&drawn)); + assert_eq!(supply(&platform, token_id), Some(BASE_SUPPLY + drawn)); + assert_eq!( + balance(&platform, token_id, identity.id()), + Some(BASE_SUPPLY) + ); + assert_tokens_balanced(&platform); + assert_eq!( + proved_amount(verify_proof(&platform, &transition, &contract)), + drawn + ); + } + + #[tokio::test] + async fn should_refuse_a_random_mint_without_a_recipient() { + 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(49857); + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration + .distribution_rules_mut() + .set_new_tokens_destination_identity(Some(identity.id())); + }), + None, + None, + None, + platform_version, + ); + + let transition = BatchTransition::new_token_mint_transition( + token_id, + identity.id(), + contract.id(), + 0, + MAXIMUM, + None, + None, + None, + &key, + NONCE, + 0, + &signer, + platform_version, + random_options(), + ) + .await + .expect("expected to create the mint"); + + let error = match process(&platform, &transition) { + StateTransitionExecutionResult::UnpaidConsensusError(error) => error, + StateTransitionExecutionResult::PaidConsensusError { error, .. } => error, + other => panic!("expected the mint to be refused, got {other:?}"), + }; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::RandomTokenMintNotAllowedError(_)) + ); + assert_eq!(supply(&platform, token_id), Some(BASE_SUPPLY)); + } +} diff --git a/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs index 477a0e66db1..d6ee4c75bab 100644 --- a/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs @@ -1,5 +1,6 @@ use crate::drive::document::draw_random_document_properties::RandomDocumentRequest; use crate::drive::document::paths::contract_documents_primary_key_path; +use crate::drive::random_beacon::{draw_from_beacon_v0, push_with_length, DOCUMENT_PROPERTY_DRAW}; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; @@ -16,17 +17,10 @@ use dpp::data_contract::document_type::{ use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::{Document, DocumentV0Getters}; use dpp::platform_value::btreemap_extensions::BTreeValueMapInsertionPathHelper; -use dpp::util::hash::hash_single; use dpp::version::PlatformVersion; use grovedb::{Element, TransactionArg}; use std::borrow::Cow; -/// The domain of a draw's seed, so a beacon never seeds anything else the same way. -const DRAW_DOMAIN: &[u8] = b"dash-platform-random-v1"; - -/// What a seed draws for: a document property (the only kind in this version). -const DOCUMENT_PROPERTY_DRAW: u8 = 0; - impl Drive { #[inline(always)] pub(super) fn draw_random_document_properties_v0( @@ -181,49 +175,25 @@ impl Drive { } } -/// The value the property at `path` of the document `request` names draws from `beacon`: -/// SHA-256 of the domain, the beacon and what the draw is for (a document property, its -/// contract, type, document and path, each variable part prefixed by its length), whose first -/// 16 bytes, big-endian, reduced modulo the range's size, offset the range's minimum. A range -/// holds at most 2^64 values, so the reduction favours no value by more than 2^-64. `None` -/// only for a part longer than a length prefix holds, which registration rules out. +/// The value the property at `path` of the document `request` names draws from `beacon` +/// (`draw_from_beacon_v0`), under the request key of a document property: what the draw is +/// for, the contract, the type, the document and the path, each variable part prefixed by its +/// length. `None` only for a part longer than a length prefix holds, which registration rules +/// out. fn draw_v0( beacon: &[u8], request: &RandomDocumentRequest, path: &str, range: RandomRange, ) -> Option { - let mut preimage = Vec::with_capacity( - DRAW_DOMAIN.len() - + 2 - + beacon.len() - + 1 - + 32 - + 2 - + request.document_type_name.len() - + 32 - + 2 - + path.len(), - ); - preimage.extend_from_slice(DRAW_DOMAIN); - push_with_length(&mut preimage, beacon)?; - preimage.push(DOCUMENT_PROPERTY_DRAW); - preimage.extend_from_slice(request.contract_id.as_slice()); - push_with_length(&mut preimage, request.document_type_name.as_bytes())?; - preimage.extend_from_slice(request.document_id.as_slice()); - push_with_length(&mut preimage, path.as_bytes())?; - let seed = hash_single(&preimage); - let mut bits = [0u8; 16]; - bits.copy_from_slice(&seed[..16]); - range.value_at(u128::from_be_bytes(bits) % range.size()) -} - -/// Appends `part` after its length as a big-endian `u16`. -fn push_with_length(preimage: &mut Vec, part: &[u8]) -> Option<()> { - let length = u16::try_from(part.len()).ok()?; - preimage.extend_from_slice(&length.to_be_bytes()); - preimage.extend_from_slice(part); - Some(()) + let mut request_key = + Vec::with_capacity(1 + 32 + 2 + request.document_type_name.len() + 32 + 2 + path.len()); + request_key.push(DOCUMENT_PROPERTY_DRAW); + request_key.extend_from_slice(request.contract_id.as_slice()); + push_with_length(&mut request_key, request.document_type_name.as_bytes())?; + request_key.extend_from_slice(request.document_id.as_slice()); + push_with_length(&mut request_key, path.as_bytes())?; + draw_from_beacon_v0(beacon, &request_key, range) } #[cfg(test)] diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs index 73a418e51f3..e2c378d13ae 100644 --- a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs @@ -201,8 +201,9 @@ impl Drive { /// types a client cannot update, split out unchanged (the refusal reads only the document /// type, which dropping storage flags leaves as it is), so an update gathers the same /// operations as before at every protocol version. `draw_random_document_properties` - /// calls it to fill the `sys.random` properties of a document of any type. - pub(in crate::drive::document) fn update_stored_document_operations_v1( + /// calls it to fill the `sys.random` properties of a document of any type, and + /// `settle_random_token_amounts` to fill the drawn amount into a token history document. + pub(crate) fn update_stored_document_operations_v1( &self, document_and_contract_info: DocumentAndContractInfo, block_info: &BlockInfo, diff --git a/packages/rs-drive/src/drive/mod.rs b/packages/rs-drive/src/drive/mod.rs index 120bc8f1e45..084e1df0a27 100644 --- a/packages/rs-drive/src/drive/mod.rs +++ b/packages/rs-drive/src/drive/mod.rs @@ -46,6 +46,11 @@ pub mod initialization; #[cfg(any(feature = "server", feature = "verify"))] pub mod protocol_upgrade; +/// The values a block's random beacon draws: `sys.random` document properties and random +/// token amounts +#[cfg(feature = "server")] +pub(crate) mod random_beacon; + /// System module #[cfg(feature = "server")] pub mod system; diff --git a/packages/rs-drive/src/drive/random_beacon/mod.rs b/packages/rs-drive/src/drive/random_beacon/mod.rs new file mode 100644 index 00000000000..21022043bc9 --- /dev/null +++ b/packages/rs-drive/src/drive/random_beacon/mod.rs @@ -0,0 +1,122 @@ +//! The values a block's random beacon draws (protocol version 15). +//! +//! The beacon is the threshold signature the validators' prevotes for a block combine into, +//! which no one can compute before the block is accepted. Every random value of the block is +//! drawn from it the same way, under a request key that names what the value is for, so two +//! requests of one block never share a seed: +//! +//! - a `sys.random` document property (`Drive::draw_random_document_properties`): +//! `0x00 || contract_id || len16(document_type_name) || document_id || len16(property_path)`; +//! - a random token amount (`Drive::settle_random_token_amounts`): +//! `0x01 || token_id || owner_id || identity_contract_nonce` (a big-endian `u64`). +//! +//! `len16(x)` is `x` after its length as a big-endian `u16`. + +use dpp::data_contract::document_type::RandomRange; +use dpp::util::hash::hash_single; + +/// The domain of a draw's seed, so a beacon never seeds anything else the same way. +const DRAW_DOMAIN: &[u8] = b"dash-platform-random-v1"; + +/// The first byte of the request key of a `sys.random` document property. +pub(crate) const DOCUMENT_PROPERTY_DRAW: u8 = 0; + +/// The first byte of the request key of a random token amount. +pub(crate) const TOKEN_AMOUNT_DRAW: u8 = 1; + +/// The value `beacon` draws for `request_key` from `range`: SHA-256 of the domain, the beacon +/// (prefixed by its length) and the request key, whose first 16 bytes, big-endian, reduced +/// modulo the range's size, offset the range's minimum. A range holds at most 2^64 values, so +/// the reduction favours no value by more than 2^-64. `None` only for a beacon longer than a +/// length prefix holds, or a range whose minimum is above its maximum, which no caller passes. +pub(crate) fn draw_from_beacon_v0( + beacon: &[u8], + request_key: &[u8], + range: RandomRange, +) -> Option { + if range.minimum > range.maximum { + return None; + } + let mut preimage = Vec::with_capacity(DRAW_DOMAIN.len() + 2 + beacon.len() + request_key.len()); + preimage.extend_from_slice(DRAW_DOMAIN); + push_with_length(&mut preimage, beacon)?; + preimage.extend_from_slice(request_key); + let seed = hash_single(&preimage); + let mut bits = [0u8; 16]; + bits.copy_from_slice(seed.get(..16)?); + range.value_at(u128::from_be_bytes(bits) % range.size()) +} + +/// Appends `part` after its length as a big-endian `u16`; `None` for a part longer than that +/// holds. +pub(crate) fn push_with_length(preimage: &mut Vec, part: &[u8]) -> Option<()> { + let length = u16::try_from(part.len()).ok()?; + preimage.extend_from_slice(&length.to_be_bytes()); + preimage.extend_from_slice(part); + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_seed_each_request_key_and_beacon_apart() { + let range = RandomRange { + minimum: 0, + maximum: u64::MAX.into(), + }; + let beacon = [7u8; 96]; + assert_eq!( + draw_from_beacon_v0(&beacon, &[1, 2, 3], range), + draw_from_beacon_v0(&beacon, &[1, 2, 3], range) + ); + assert_ne!( + draw_from_beacon_v0(&beacon, &[1, 2, 3], range), + draw_from_beacon_v0(&beacon, &[1, 2, 4], range) + ); + assert_ne!( + draw_from_beacon_v0(&beacon, &[1, 2, 3], range), + draw_from_beacon_v0(&[8u8; 96], &[1, 2, 3], range) + ); + } + + #[test] + fn should_draw_inside_the_range_and_refuse_an_inverted_one() { + let beacon = [7u8; 96]; + for key in 0u8..32 { + let drawn = draw_from_beacon_v0( + &beacon, + &[key], + RandomRange { + minimum: 10, + maximum: 12, + }, + ) + .expect("draws"); + assert!((10..=12).contains(&drawn)); + } + assert_eq!( + draw_from_beacon_v0( + &beacon, + &[0], + RandomRange { + minimum: 5, + maximum: 5, + } + ), + Some(5) + ); + assert_eq!( + draw_from_beacon_v0( + &beacon, + &[0], + RandomRange { + minimum: 6, + maximum: 5, + } + ), + None + ); + } +} diff --git a/packages/rs-drive/src/drive/tokens/mod.rs b/packages/rs-drive/src/drive/tokens/mod.rs index e5127d9461a..6972c66189e 100644 --- a/packages/rs-drive/src/drive/tokens/mod.rs +++ b/packages/rs-drive/src/drive/tokens/mod.rs @@ -39,6 +39,11 @@ pub mod mint_many; #[cfg(feature = "server")] pub mod shielded_pool; +/// Pays out the random token transfers and mints of a block from its random beacon +/// (protocol version 15). +#[cfg(feature = "server")] +pub mod settle_random_token_amounts; + /// Manages system-level operations and utilities. #[cfg(feature = "server")] pub mod system; diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs new file mode 100644 index 00000000000..61fc4646737 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs @@ -0,0 +1,152 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::balances::credits::TokenAmount; +use dpp::block::block_info::BlockInfo; +use dpp::prelude::{Identifier, IdentityNonce}; +use dpp::util::hash::hash_double; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// The domain of a random token reservation's id. +const RANDOM_TOKEN_RESERVATION_DOMAIN: &[u8] = b"random_token_reservation"; + +/// The key under which the token marketplace escrow of `token_id` +/// (`[Tokens, TOKEN_ORDER_ESCROW_KEY, token id]`) holds the reservation of the random token +/// transfer or mint `owner_id` sent with `identity_contract_nonce`: +/// `hash_double("random_token_reservation" || token_id || owner_id || identity_contract_nonce)`, +/// the nonce a big-endian `u64`. The block executing the transition writes the entry +/// (`TokenOperationType::TokenReserveRandomTransfer` and `TokenReserveRandomMint`) and +/// `Drive::settle_random_token_amounts` removes it. The domain keeps it apart from the order ids +/// of the marketplace's listings, and the nonce, used once per owner and contract, keeps two +/// reservations of a block apart. +pub fn random_token_reservation_id( + token_id: &Identifier, + owner_id: &Identifier, + identity_contract_nonce: IdentityNonce, +) -> [u8; 32] { + let mut preimage = Vec::with_capacity(RANDOM_TOKEN_RESERVATION_DOMAIN.len() + 32 + 32 + 8); + preimage.extend_from_slice(RANDOM_TOKEN_RESERVATION_DOMAIN); + preimage.extend_from_slice(token_id.as_slice()); + preimage.extend_from_slice(owner_id.as_slice()); + preimage.extend_from_slice(&identity_contract_nonce.to_be_bytes()); + hash_double(preimage) +} + +/// What a random token amount pays out. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RandomTokenRequestKind { + /// A random token transfer: the block moved the request's maximum from the sender into + /// the reservation, and credited the recipient zero. + Transfer { + /// The identity that sent the tokens + sender_id: Identifier, + /// The identity receiving the drawn amount + recipient_id: Identifier, + }, + /// A random token mint: the block added the request's maximum to the token's total supply + /// and held it in the reservation, and credited the recipient zero. + Mint { + /// The identity receiving the drawn amount + recipient_id: Identifier, + }, +} + +/// A token transfer or mint of a block that moves a random amount (protocol version 15), whose +/// maximum the block reserved when it executed the transition +/// (`TokenOperationType::TokenReserveRandomTransfer` and `TokenReserveRandomMint`): it holds +/// the maximum in the token marketplace escrow of the token, under +/// [`random_token_reservation_id`], and gave the recipient a balance (of zero if it had none). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RandomTokenRequest { + /// The token + pub token_id: Identifier, + /// The identity that sent the transition + pub owner_id: Identifier, + /// The identity contract nonce of the transition, which with the token and the owner keys + /// the draw and the transition's history document + pub identity_contract_nonce: IdentityNonce, + /// The least the transition moves + pub minimum: TokenAmount, + /// The most the transition moves, which the block reserved + pub maximum: TokenAmount, + /// What the drawn amount pays out + pub kind: RandomTokenRequestKind, +} + +impl RandomTokenRequest { + /// The key of the request's reservation in the token's escrow tree. + pub fn reservation_id(&self) -> [u8; 32] { + random_token_reservation_id(&self.token_id, &self.owner_id, self.identity_contract_nonce) + } +} + +impl Drive { + /// Pays out the random token transfers and mints of a block (protocol version 15) once + /// every transition of the block is accepted, from `beacon`, the block's random beacon (the + /// threshold signature the block's prevotes combine into). For each request it draws an + /// amount `v` from the request's minimum to its maximum, both included, and settles what + /// the block reserved. It removes the reservation, the maximum the token marketplace escrow + /// of the token held, and: + /// + /// - a transfer credits the recipient `v` and gives the sender back the rest of the + /// maximum; + /// - a mint credits the recipient `v` and takes the rest of the maximum off the token's + /// total supply. + /// + /// The recipient's balance exists, of zero if it had none: the block wrote it when it + /// executed the transition, which paid for its storage, so the settlement only replaces + /// values. Token conservation holds before the settlement, with the maximum counted in the + /// escrow, and after it. + /// + /// When the token keeps the history of the kind, the transition's history document, which + /// holds the range's minimum, is rewritten in place to hold `v`. Settling only pays out or + /// releases what the block reserved, so it cannot fail for a logical reason. Nobody pays: + /// the transition paid for its execution when the block ran it, and the settlement is free. + /// + /// # Parameters + /// - `beacon`: the block's random beacon. + /// - `requests`: the random token transfers and mints of the block, in the order the block + /// executed them. + /// - `block_info`: the block. + /// - `transaction`: the block's transaction. + /// - `platform_version`: selects the method version; `None` before protocol version 15, + /// which is the first to decode a random token amount. + /// + /// # Returns + /// How many requests were settled. + pub fn settle_random_token_amounts( + &self, + beacon: &[u8], + requests: &[RandomTokenRequest], + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .token + .settle_random_token_amounts + { + Some(0) => self.settle_random_token_amounts_v0( + beacon, + requests, + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "settle_random_token_amounts".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "settle_random_token_amounts".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs new file mode 100644 index 00000000000..bc6d254d3c6 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs @@ -0,0 +1,788 @@ +use crate::drive::document::paths::contract_documents_primary_key_path; +use crate::drive::random_beacon::{draw_from_beacon_v0, TOKEN_AMOUNT_DRAW}; +use crate::drive::tokens::settle_random_token_amounts::{ + RandomTokenRequest, RandomTokenRequestKind, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::DirectQueryType; +use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use dpp::balances::credits::TokenAmount; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::{integer_value_for_type, RandomRange}; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::prelude::DataContract; +use dpp::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; +use std::borrow::Cow; + +/// The property of a transfer or mint history document holding the amount. +const HISTORY_AMOUNT_PROPERTY: &str = "amount"; + +impl Drive { + #[inline(always)] + pub(super) fn settle_random_token_amounts_v0( + &self, + beacon: &[u8], + requests: &[RandomTokenRequest], + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut settled = 0u32; + for request in requests { + self.settle_random_token_amount_v0( + beacon, + request, + block_info, + transaction, + platform_version, + )?; + settled = settled.saturating_add(1); + } + Ok(settled) + } + + /// Draws the amount of `request` and settles what its transition reserved. Each request is + /// applied before the next is read, so two requests of a block crediting one balance each + /// add to what the other left. + fn settle_random_token_amount_v0( + &self, + beacon: &[u8], + request: &RandomTokenRequest, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let drawn = draw_token_amount_v0(beacon, request).ok_or(Error::Drive( + DriveError::CorruptedCodeExecution( + "structure validation keeps a random token amount's minimum below its maximum", + ), + ))?; + // What the reservation holds beyond the drawn amount + let released = request.maximum.checked_sub(drawn).ok_or(Error::Drive( + DriveError::CorruptedCodeExecution( + "a drawn token amount is at most the maximum it is drawn up to", + ), + ))?; + let token_id = request.token_id.to_buffer(); + + // The reservation holds exactly the maximum: the block's execution of the transition + // wrote it, and nothing else writes under a reservation id. Anything else is a request + // no executed transition reserved, which is never paid out. + let reservation_id = request.reservation_id(); + let reserved = self.fetch_token_order_escrow( + token_id, + reservation_id, + transaction, + &mut vec![], + platform_version, + )?; + if reserved != Some(request.maximum) { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a random token amount is settled only from the reservation its execution wrote", + ))); + } + let mut operations = self.remove_token_order_escrow_operations( + token_id, + reservation_id, + &mut None, + platform_version, + )?; + + let history_document_type_name = match request.kind { + RandomTokenRequestKind::Transfer { + sender_id, + recipient_id, + } => { + operations.extend(self.add_to_identity_token_balance_operations( + token_id, + recipient_id.to_buffer(), + drawn, + &mut None, + transaction, + platform_version, + )?); + if released > 0 { + // The sender and the recipient differ (structure validation refuses a + // transfer to oneself), so the two writes are to two balances + operations.extend(self.add_to_identity_token_balance_operations( + token_id, + sender_id.to_buffer(), + released, + &mut None, + transaction, + platform_version, + )?); + } + "transfer" + } + RandomTokenRequestKind::Mint { recipient_id } => { + operations.extend(self.add_to_identity_token_balance_operations( + token_id, + recipient_id.to_buffer(), + drawn, + &mut None, + transaction, + platform_version, + )?); + if released > 0 { + operations.extend(self.remove_from_token_total_supply_operations( + token_id, + released, + &mut None, + transaction, + platform_version, + )?); + } + "mint" + } + }; + operations.extend(self.random_token_history_amount_operations_v0( + request, + history_document_type_name, + drawn, + block_info, + transaction, + platform_version, + )?); + + // No fee: the transition paid for its execution, which wrote the recipient's balance + // and the history document the settlement only replaces, and the reservation it removes + self.apply_batch_low_level_drive_operations( + None, + transaction, + operations, + &mut vec![], + &platform_version.drive, + ) + } + + /// The operations rewriting the history document of `request`'s transition to hold + /// `amount`, none when the token keeps no history of the kind: the transition then wrote no + /// history document. The document is found the way it was written + /// (`TokenEvent::build_historical_document_owned`), and rewritten in place with its own + /// storage flags, its `byAmount` index entry moved to the drawn amount. + fn random_token_history_amount_operations_v0( + &self, + request: &RandomTokenRequest, + document_type_name: &str, + amount: TokenAmount, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let token_history_contract = self + .cache + .system_data_contracts + .load_token_history(platform_version)?; + let contract: &DataContract = &token_history_contract; + let document_type = contract + .document_type_for_name(document_type_name) + .map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "the token history contract defines the transfer and mint types", + )) + })?; + let document_id = Document::generate_document_id_v0( + &request.token_id, + &request.owner_id, + format!("history_{}", document_type_name).as_str(), + request.identity_contract_nonce.to_be_bytes().as_slice(), + ); + + let primary_key_path = + contract_documents_primary_key_path(contract.id_ref().as_bytes(), document_type_name); + let element = self.grove_get_raw_optional( + (&primary_key_path).into(), + document_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )?; + let (serialized, element_flags) = match element { + Some(Element::Item(serialized, flags)) + | Some(Element::ItemWithSumItem(serialized, _, flags)) => (serialized, flags), + // The token keeps no history of the kind + None => return Ok(vec![]), + Some(_) => { + return Err(Error::Drive(DriveError::CorruptedDocumentNotItem( + "a token history document is stored as an item", + ))) + } + }; + let mut document = Document::from_bytes(&serialized, document_type, platform_version)?; + let storage_flags = StorageFlags::map_some_element_flags_ref(&element_flags)?; + + let amount_type = &document_type + .flattened_properties() + .get(HISTORY_AMOUNT_PROPERTY) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a token history transfer or mint document has an amount", + )))? + .property_type; + let value = integer_value_for_type(amount_type, i128::from(amount)).ok_or(Error::Drive( + DriveError::CorruptedCodeExecution( + "a token history amount is an integer holding every token amount", + ), + ))?; + document + .properties_mut() + .insert(HISTORY_AMOUNT_PROPERTY.to_string(), value); + + let owner_id = document.owner_id().to_buffer(); + self.update_stored_document_operations_v1( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentOwnedInfo((document, storage_flags.map(Cow::Owned))), + owner_id: Some(owner_id), + }, + contract, + document_type, + }, + block_info, + &mut None, + &mut None, + transaction, + platform_version, + ) + } +} + +/// The amount `beacon` draws for `request` (`draw_from_beacon_v0`), from its minimum to its +/// maximum, under the request key of a token amount: what the draw is for, the token, the owner +/// and the identity contract nonce (a big-endian `u64`). The nonce is used once per owner and +/// contract, so the requests of a block never share a key. `None` only for a minimum above the +/// maximum, which structure validation rules out. +fn draw_token_amount_v0(beacon: &[u8], request: &RandomTokenRequest) -> Option { + let mut request_key = Vec::with_capacity(1 + 32 + 32 + 8); + request_key.push(TOKEN_AMOUNT_DRAW); + request_key.extend_from_slice(request.token_id.as_slice()); + request_key.extend_from_slice(request.owner_id.as_slice()); + request_key.extend_from_slice(&request.identity_contract_nonce.to_be_bytes()); + let drawn = draw_from_beacon_v0( + beacon, + &request_key, + RandomRange { + minimum: i128::from(request.minimum), + maximum: i128::from(request.maximum), + }, + )?; + TokenAmount::try_from(drawn).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::drive::tokens::settle_random_token_amounts::random_token_reservation_id; + use crate::util::batch::drive_op_batch::TokenOperationType; + use crate::util::batch::DriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::platform_value::Value; + use dpp::prelude::Identifier; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::tokens::token_event::TokenEvent; + + const BEACON: [u8; 96] = [7; 96]; + const TOKEN: [u8; 32] = [5; 32]; + const SENDER: [u8; 32] = [6; 32]; + const RECIPIENT: [u8; 32] = [8; 32]; + const NONCE: u64 = 3; + const BASE_SUPPLY: TokenAmount = 10_000; + const MINIMUM: TokenAmount = 100; + const MAXIMUM: TokenAmount = 1_000; + + /// A drive holding the token history contract and a token whose whole base supply the + /// sender holds. + fn setup() -> Drive { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let token_history = + load_system_data_contract(SystemDataContract::TokenHistory, platform_version) + .expect("expected the token history contract"); + drive + .apply_contract( + &token_history, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("expected to store the token history contract"); + drive + .create_token_trees( + Identifier::from([4; 32]), + 0, + TOKEN, + false, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to create the token trees"); + drive + .token_mint( + TOKEN, + SENDER, + BASE_SUPPLY, + false, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to mint the base supply"); + drive + } + + fn request(kind: RandomTokenRequestKind) -> RandomTokenRequest { + RandomTokenRequest { + token_id: Identifier::from(TOKEN), + owner_id: Identifier::from(SENDER), + identity_contract_nonce: NONCE, + minimum: MINIMUM, + maximum: MAXIMUM, + kind, + } + } + + fn transfer() -> RandomTokenRequest { + request(RandomTokenRequestKind::Transfer { + sender_id: Identifier::from(SENDER), + recipient_id: Identifier::from(RECIPIENT), + }) + } + + fn mint() -> RandomTokenRequest { + request(RandomTokenRequestKind::Mint { + recipient_id: Identifier::from(RECIPIENT), + }) + } + + /// Applies what a random transfer or mint gathers when the block executes it: the + /// reservation, and the history document holding the range's minimum when `with_history`. + fn reserve(drive: &Drive, request: &RandomTokenRequest, with_history: bool) { + let token_id = request.token_id; + let (reservation, event) = match request.kind { + RandomTokenRequestKind::Transfer { + sender_id, + recipient_id, + } => ( + TokenOperationType::TokenReserveRandomTransfer { + token_id, + sender_id, + recipient_id, + identity_contract_nonce: request.identity_contract_nonce, + maximum: request.maximum, + }, + TokenEvent::Transfer(recipient_id, None, None, None, request.minimum), + ), + RandomTokenRequestKind::Mint { recipient_id } => ( + TokenOperationType::TokenReserveRandomMint { + token_id, + owner_id: request.owner_id, + recipient_id, + identity_contract_nonce: request.identity_contract_nonce, + maximum: request.maximum, + allow_first_mint: false, + }, + TokenEvent::Mint(request.minimum, recipient_id, None), + ), + }; + let mut operations = vec![DriveOperation::TokenOperation(reservation)]; + if with_history { + operations.push(DriveOperation::TokenOperation( + TokenOperationType::TokenHistory { + token_id, + owner_id: request.owner_id, + nonce: request.identity_contract_nonce, + event, + }, + )); + } + drive + .apply_drive_operations( + operations, + true, + &BlockInfo::default(), + None, + PlatformVersion::latest(), + None, + ) + .expect("expected the reservation to apply"); + } + + fn settle(drive: &Drive, requests: &[RandomTokenRequest]) -> u32 { + drive + .settle_random_token_amounts( + &BEACON, + requests, + &BlockInfo::default(), + None, + PlatformVersion::latest(), + ) + .expect("expected the settlement to run") + } + + fn balance(drive: &Drive, identity_id: [u8; 32]) -> Option { + drive + .fetch_identity_token_balance(TOKEN, identity_id, None, PlatformVersion::latest()) + .expect("expected to fetch a token balance") + } + + fn supply(drive: &Drive) -> Option { + drive + .fetch_token_total_supply(TOKEN, None, PlatformVersion::latest()) + .expect("expected to fetch the total supply") + } + + /// What the token marketplace escrow holds under `request`'s reservation. + fn reserved(drive: &Drive, request: &RandomTokenRequest) -> Option { + drive + .fetch_token_order_escrow( + TOKEN, + request.reservation_id(), + None, + &mut vec![], + PlatformVersion::latest(), + ) + .expect("expected to fetch the reservation") + } + + /// The identity balances, the shielded pools and the escrow add up to the total supplies. + fn assert_tokens_balanced(drive: &Drive) { + let total = drive + .calculate_total_tokens_balance(None, PlatformVersion::latest()) + .expect("expected to sum the token balances"); + assert!( + total.ok().expect("expected the sums to compare"), + "tokens are not balanced: {total}" + ); + } + + /// The amount the history document of `request`'s transition holds. + fn history_amount( + drive: &Drive, + request: &RandomTokenRequest, + document_type_name: &str, + ) -> Option { + let platform_version = PlatformVersion::latest(); + let contract = drive + .cache + .system_data_contracts + .load_token_history(platform_version) + .expect("expected the token history contract"); + let document_type = contract + .document_type_for_name(document_type_name) + .expect("expected the history type"); + let document_id = Document::generate_document_id_v0( + &request.token_id, + &request.owner_id, + format!("history_{}", document_type_name).as_str(), + request.identity_contract_nonce.to_be_bytes().as_slice(), + ); + let path = + contract_documents_primary_key_path(contract.id_ref().as_bytes(), document_type_name); + let element = drive + .grove_get_raw_optional( + (&path).into(), + document_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read the history document")?; + let Element::Item(serialized, _) = element else { + panic!("expected the history document to be an item"); + }; + let document = Document::from_bytes(&serialized, document_type, platform_version) + .expect("expected the history document to decode"); + document + .properties() + .get(HISTORY_AMOUNT_PROPERTY) + .map(|amount: &Value| amount.to_integer::().expect("an integer amount")) + } + + #[test] + fn should_settle_a_random_transfer_as_a_transfer_of_the_drawn_amount() { + let drive = setup(); + let request = transfer(); + let drawn = draw_token_amount_v0(&BEACON, &request).expect("draws"); + assert!((MINIMUM..=MAXIMUM).contains(&drawn)); + + reserve(&drive, &request, true); + assert_eq!(balance(&drive, SENDER), Some(BASE_SUPPLY - MAXIMUM)); + assert_eq!( + balance(&drive, RECIPIENT), + Some(0), + "the recipient's balance is written, and waits for the draw" + ); + assert_eq!(reserved(&drive, &request), Some(MAXIMUM)); + assert_eq!(history_amount(&drive, &request, "transfer"), Some(MINIMUM)); + assert_eq!(supply(&drive), Some(BASE_SUPPLY)); + assert_tokens_balanced(&drive); + + assert_eq!(settle(&drive, std::slice::from_ref(&request)), 1); + assert_eq!(reserved(&drive, &request), None); + + // What a plain transfer of the drawn amount leaves + let plain = setup(); + plain + .token_transfer( + TOKEN, + SENDER, + RECIPIENT, + drawn, + &BlockInfo::default(), + true, + None, + PlatformVersion::latest(), + ) + .expect("expected the plain transfer"); + assert_eq!(balance(&drive, SENDER), balance(&plain, SENDER)); + assert_eq!(balance(&drive, RECIPIENT), balance(&plain, RECIPIENT)); + assert_eq!(balance(&drive, RECIPIENT), Some(drawn)); + assert_eq!(supply(&drive), supply(&plain)); + assert_eq!(supply(&drive), Some(BASE_SUPPLY)); + assert_eq!(history_amount(&drive, &request, "transfer"), Some(drawn)); + assert_tokens_balanced(&drive); + } + + #[test] + fn should_settle_a_random_mint_as_a_mint_of_the_drawn_amount() { + let drive = setup(); + let request = mint(); + let drawn = draw_token_amount_v0(&BEACON, &request).expect("draws"); + + reserve(&drive, &request, true); + assert_eq!(supply(&drive), Some(BASE_SUPPLY + MAXIMUM)); + assert_eq!( + balance(&drive, RECIPIENT), + Some(0), + "the recipient's balance is written, and waits for the draw" + ); + assert_eq!(reserved(&drive, &request), Some(MAXIMUM)); + assert_eq!(history_amount(&drive, &request, "mint"), Some(MINIMUM)); + assert_tokens_balanced(&drive); + + assert_eq!(settle(&drive, std::slice::from_ref(&request)), 1); + assert_eq!(reserved(&drive, &request), None); + + // What a plain mint of the drawn amount leaves + let plain = setup(); + plain + .token_mint( + TOKEN, + RECIPIENT, + drawn, + false, + false, + &BlockInfo::default(), + true, + None, + PlatformVersion::latest(), + ) + .expect("expected the plain mint"); + assert_eq!(balance(&drive, RECIPIENT), balance(&plain, RECIPIENT)); + assert_eq!(balance(&drive, RECIPIENT), Some(drawn)); + assert_eq!(balance(&drive, SENDER), balance(&plain, SENDER)); + assert_eq!(supply(&drive), supply(&plain)); + assert_eq!(supply(&drive), Some(BASE_SUPPLY + drawn)); + assert_eq!(history_amount(&drive, &request, "mint"), Some(drawn)); + assert_tokens_balanced(&drive); + } + + #[test] + fn should_settle_without_a_history_document_when_the_token_keeps_none() { + let drive = setup(); + let request = transfer(); + let drawn = draw_token_amount_v0(&BEACON, &request).expect("draws"); + + reserve(&drive, &request, false); + assert_tokens_balanced(&drive); + assert_eq!(settle(&drive, std::slice::from_ref(&request)), 1); + assert_eq!(balance(&drive, RECIPIENT), Some(drawn)); + assert_eq!(balance(&drive, SENDER), Some(BASE_SUPPLY - drawn)); + assert_eq!(history_amount(&drive, &request, "transfer"), None); + assert_tokens_balanced(&drive); + } + + #[test] + fn should_settle_two_requests_crediting_one_balance() { + let drive = setup(); + let first = transfer(); + let second = RandomTokenRequest { + identity_contract_nonce: NONCE + 1, + ..transfer() + }; + let first_drawn = draw_token_amount_v0(&BEACON, &first).expect("draws"); + let second_drawn = draw_token_amount_v0(&BEACON, &second).expect("draws"); + + reserve(&drive, &first, true); + reserve(&drive, &second, true); + assert_ne!(first.reservation_id(), second.reservation_id()); + assert_eq!(reserved(&drive, &first), Some(MAXIMUM)); + assert_eq!(reserved(&drive, &second), Some(MAXIMUM)); + assert_tokens_balanced(&drive); + assert_eq!(settle(&drive, &[first.clone(), second.clone()]), 2); + assert_eq!(balance(&drive, RECIPIENT), Some(first_drawn + second_drawn)); + assert_eq!( + balance(&drive, SENDER), + Some(BASE_SUPPLY - first_drawn - second_drawn) + ); + assert_eq!( + history_amount(&drive, &first, "transfer"), + Some(first_drawn) + ); + assert_eq!( + history_amount(&drive, &second, "transfer"), + Some(second_drawn) + ); + assert_tokens_balanced(&drive); + } + + #[test] + fn should_refuse_to_settle_a_request_nothing_reserved() { + let drive = setup(); + let request = transfer(); + let result = drive.settle_random_token_amounts( + &BEACON, + std::slice::from_ref(&request), + &BlockInfo::default(), + None, + PlatformVersion::latest(), + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + )); + assert_eq!(balance(&drive, RECIPIENT), None, "nothing is paid out"); + assert_eq!(balance(&drive, SENDER), Some(BASE_SUPPLY)); + + // A reservation of another maximum is not this request's either + reserve(&drive, &request, false); + let result = drive.settle_random_token_amounts( + &BEACON, + &[RandomTokenRequest { + maximum: MAXIMUM + 1, + ..transfer() + }], + &BlockInfo::default(), + None, + PlatformVersion::latest(), + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + )); + } + + #[test] + fn should_key_each_reservation_by_token_owner_and_nonce() { + let token_id = Identifier::from(TOKEN); + let owner_id = Identifier::from(SENDER); + let reservation = random_token_reservation_id(&token_id, &owner_id, NONCE); + assert_eq!(transfer().reservation_id(), reservation); + assert_eq!(mint().reservation_id(), reservation); + assert_ne!( + random_token_reservation_id(&token_id, &owner_id, NONCE + 1), + reservation + ); + assert_ne!( + random_token_reservation_id(&Identifier::from([9; 32]), &owner_id, NONCE), + reservation + ); + assert_ne!( + random_token_reservation_id(&token_id, &Identifier::from([9; 32]), NONCE), + reservation + ); + } + + #[test] + fn should_draw_each_token_amount_reproducibly() { + let request = transfer(); + let widest = RandomTokenRequest { + minimum: 0, + maximum: u64::MAX, + ..transfer() + }; + assert_eq!( + draw_token_amount_v0(&BEACON, &request), + draw_token_amount_v0(&BEACON, &request) + ); + assert_ne!( + draw_token_amount_v0(&BEACON, &widest), + draw_token_amount_v0( + &BEACON, + &RandomTokenRequest { + identity_contract_nonce: NONCE + 1, + ..widest.clone() + } + ) + ); + assert_ne!( + draw_token_amount_v0(&BEACON, &widest), + draw_token_amount_v0(&[8; 96], &widest) + ); + // A transfer and a mint of one owner, token and nonce draw alike: one transition is + // either, never both + assert_eq!( + draw_token_amount_v0(&BEACON, &request), + draw_token_amount_v0(&BEACON, &mint()) + ); + // The draw is consensus: every node must compute these values forever + assert_eq!( + draw_token_amount_v0(&BEACON, &widest), + Some(TOKEN_DRAW_VECTOR) + ); + assert_eq!( + draw_token_amount_v0(&BEACON, &request), + Some(TOKEN_RANGE_DRAW_VECTOR) + ); + assert_eq!( + draw_token_amount_v0( + &BEACON, + &RandomTokenRequest { + minimum: 2, + maximum: 1, + ..transfer() + } + ), + None + ); + } + + /// The draw of [`transfer`] over the widest range, from 0 to `u64::MAX`. + const TOKEN_DRAW_VECTOR: TokenAmount = 10_714_275_481_292_403_667; + /// The draw of [`transfer`] over its range, from 100 to 1_000. + const TOKEN_RANGE_DRAW_VECTOR: TokenAmount = 298; + + #[test] + fn should_not_settle_before_protocol_version_15() { + let drive = setup(); + let result = drive.settle_random_token_amounts( + &BEACON, + &[], + &BlockInfo::default(), + None, + PlatformVersion::get(14).expect("protocol version 14"), + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + )); + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_mint_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_mint_transition.rs index c17296d7083..8a7759646a7 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_mint_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_mint_transition.rs @@ -81,13 +81,36 @@ impl DriveHighLevelBatchOperationConverter for TokenMintTransitionAction { } if self.base().perform_action() { - ops.push(TokenOperation(TokenOperationType::TokenMint { - token_id: self.token_id(), - identity_balance_holder_id: self.identity_balance_holder_id(), - mint_amount: self.mint_amount(), - allow_first_mint: false, - allow_saturation: false, - })); + // A random mint (protocol version 15) adds the most it issues to the total + // supply, holds it in a reservation and pays the recipient once the block's + // random beacon is known (`Drive::settle_random_token_amounts`), and its + // history holds the range's minimum until then. Only a version 1 mint carries a random minimum + // amount, and no protocol version before 15 decodes one + // (`StateTransition::active_version_range`), so every earlier version that + // selects this generation takes the other branch, as it always did. + let history_amount = match self.random_minimum_amount() { + Some(minimum) => { + ops.push(TokenOperation(TokenOperationType::TokenReserveRandomMint { + token_id: self.token_id(), + owner_id, + recipient_id: self.identity_balance_holder_id(), + identity_contract_nonce, + maximum: self.mint_amount(), + allow_first_mint: false, + })); + minimum + } + None => { + ops.push(TokenOperation(TokenOperationType::TokenMint { + token_id: self.token_id(), + identity_balance_holder_id: self.identity_balance_holder_id(), + mint_amount: self.mint_amount(), + allow_first_mint: false, + allow_saturation: false, + })); + self.mint_amount() + } + }; let token_configuration = self.base().token_configuration()?; if token_configuration.keeps_history().keeps_minting_history() { @@ -96,7 +119,7 @@ impl DriveHighLevelBatchOperationConverter for TokenMintTransitionAction { owner_id, nonce: identity_contract_nonce, event: TokenEvent::Mint( - self.mint_amount(), + history_amount, self.identity_balance_holder_id(), self.public_note_owned(), ), diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transfer_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transfer_transition.rs index e84ed462f54..125f95a1060 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transfer_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transfer_transition.rs @@ -47,12 +47,36 @@ impl DriveHighLevelBatchOperationConverter for TokenTransferTransitionAction { }, )]; - ops.push(TokenOperation(TokenOperationType::TokenTransfer { - token_id, - sender_id: owner_id, - recipient_id, - amount, - })); + // A random transfer (protocol version 15) moves the most it moves from the + // sender into a reservation and pays the recipient once the block's random + // beacon is known (`Drive::settle_random_token_amounts`), and its history holds + // the range's minimum until then. Only a version 1 transfer carries a random minimum amount, + // and no protocol version before 15 decodes one + // (`StateTransition::active_version_range`), so every earlier version that + // selects this generation takes the other branch, as it always did. + let history_amount = match self.random_minimum_amount() { + Some(minimum) => { + ops.push(TokenOperation( + TokenOperationType::TokenReserveRandomTransfer { + token_id, + sender_id: owner_id, + recipient_id, + identity_contract_nonce, + maximum: amount, + }, + )); + minimum + } + None => { + ops.push(TokenOperation(TokenOperationType::TokenTransfer { + token_id, + sender_id: owner_id, + recipient_id, + amount, + })); + amount + } + }; let token_configuration = self.base().token_configuration()?; if token_configuration.keeps_history().keeps_transfer_history() { @@ -67,7 +91,7 @@ impl DriveHighLevelBatchOperationConverter for TokenTransferTransitionAction { public_note, shared_encrypted_note, private_encrypted_note, - amount, + history_amount, ), })); } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs index c63d0d0326b..4d9eb7af645 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs @@ -97,7 +97,9 @@ impl DriveHighLevelBatchOperationConverter for TokenTransitionAction { } impl TokenTransitionAction { - /// Gets the associated token event for the transition action + /// Gets the associated token event for the transition action. For a random transfer or mint + /// (protocol version 15) the amount is the range's minimum, the placeholder its execution + /// writes into the history until the block's random token amounts are settled. pub fn associated_token_event(&self) -> TokenEvent { match self { TokenTransitionAction::BurnAction(burn_action) => TokenEvent::Burn( @@ -106,7 +108,9 @@ impl TokenTransitionAction { burn_action.public_note().cloned(), ), TokenTransitionAction::MintAction(mint_action) => TokenEvent::Mint( - mint_action.mint_amount(), + mint_action + .random_minimum_amount() + .unwrap_or(mint_action.mint_amount()), mint_action.identity_balance_holder_id(), mint_action.public_note().cloned(), ), @@ -118,7 +122,9 @@ impl TokenTransitionAction { public_note, shared_encrypted_note, private_encrypted_note, - transfer_action.amount(), + transfer_action + .random_minimum_amount() + .unwrap_or(transfer_action.amount()), ) } TokenTransitionAction::FreezeAction(freeze_action) => TokenEvent::Freeze( diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/mod.rs index 0d26c593749..7124401521d 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/mod.rs @@ -1,4 +1,5 @@ use derive_more::From; +use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; /// transformer module for token issuance transition action @@ -41,6 +42,12 @@ impl TokenMintTransitionActionAccessorsV0 for TokenMintTransitionAction { } } + fn random_minimum_amount(&self) -> Option { + match self { + TokenMintTransitionAction::V0(v0) => v0.random_minimum_amount, + } + } + fn identity_balance_holder_id(&self) -> Identifier { match self { TokenMintTransitionAction::V0(v0) => v0.identity_balance_holder_id, @@ -148,6 +155,7 @@ mod tests { mint_amount, identity_balance_holder_id: holder, public_note, + random_minimum_amount: None, }; TokenMintTransitionAction::V0(v0) } @@ -234,6 +242,7 @@ mod tests { mint_amount: 555, identity_balance_holder_id: Identifier::new([61u8; 32]), public_note: Some("derive_more From".to_string()), + random_minimum_amount: None, }; // The derive_more::From impl must yield an equivalent enum diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/transformer.rs index 6801171fd56..144e0e4ddd0 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/transformer.rs @@ -63,6 +63,19 @@ impl TokenMintTransitionAction { platform_version, ) } + TokenMintTransition::V1(v1) => { + TokenMintTransitionActionV0::try_from_token_mint_transition_v1_with_contract_lookup( + drive, + owner_id, + v1, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) + } } } @@ -114,6 +127,19 @@ impl TokenMintTransitionAction { platform_version, ) } + TokenMintTransition::V1(v1) => { + TokenMintTransitionActionV0::try_from_borrowed_token_mint_transition_v1_with_contract_lookup( + drive, + owner_id, + v1, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) + } } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/mod.rs index 61a9af08f8d..1046dae39a9 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/mod.rs @@ -1,6 +1,7 @@ mod transformer; use std::sync::Arc; +use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; use crate::drive::contract::DataContractFetchInfo; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; @@ -10,12 +11,18 @@ use crate::state_transition_action::batch::batched_transition::token_transition: pub struct TokenMintTransitionActionV0 { /// Base token transition action pub base: TokenBaseTransitionAction, - /// The amount of tokens to create + /// The amount of tokens to create: with `random_minimum_amount` set, the most it creates, + /// added to the total supply when the block executes it pub mint_amount: u64, /// The identity to credit the token to pub identity_balance_holder_id: Identifier, /// A public note pub public_note: Option, + /// The least the mint issues when its amount is random (protocol version 15): the + /// recipient receives an amount drawn from this to `mint_amount` from the block's random + /// beacon, and the rest is taken off the total supply again + /// (`Drive::settle_random_token_amounts`) + pub random_minimum_amount: Option, } /// Accessors for `TokenIssuanceTransitionActionV0` @@ -32,6 +39,10 @@ pub trait TokenMintTransitionActionAccessorsV0 { /// Sets the amount of tokens to issuance fn set_mint_amount(&mut self, amount: u64); + /// The least the mint issues when its amount is random, `None` for a mint of exactly + /// `mint_amount` + fn random_minimum_amount(&self) -> Option; + /// Consumes self and returns the identity balance holder ID fn identity_balance_holder_id(&self) -> Identifier; @@ -90,6 +101,10 @@ impl TokenMintTransitionActionAccessorsV0 for TokenMintTransitionActionV0 { self.mint_amount = amount; } + fn random_minimum_amount(&self) -> Option { + self.random_minimum_amount + } + fn identity_balance_holder_id(&self) -> Identifier { self.identity_balance_holder_id } @@ -184,6 +199,7 @@ mod tests { mint_amount, identity_balance_holder_id: Identifier::new([51u8; 32]), public_note: Some("mint note".to_string()), + random_minimum_amount: None, } } @@ -241,6 +257,7 @@ mod tests { mint_amount: 0, identity_balance_holder_id: Identifier::new([0u8; 32]), public_note: None, + random_minimum_amount: None, }; assert!(action_none.public_note_owned().is_none()); } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs index 808ee91c375..d90923c6219 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs @@ -5,12 +5,15 @@ use dpp::consensus::basic::BasicError; use dpp::consensus::basic::token::{ChoosingTokenMintRecipientNotAllowedError, DestinationIdentityForTokenMintingNotSetError}; use dpp::identifier::Identifier; use dpp::state_transition::batch_transition::token_mint_transition::v0::TokenMintTransitionV0; +use dpp::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV1; +use dpp::balances::credits::TokenAmount; use dpp::ProtocolError; use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use crate::drive::contract::DataContractFetchInfo; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_mint_transition_action::v0::TokenMintTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_mint_transition_action::TokenMintTransitionAction; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dpp::fee::fee_result::FeeResult; @@ -190,6 +193,7 @@ impl TokenMintTransitionActionV0 { mint_amount: amount, identity_balance_holder_id, public_note: change_note.unwrap_or(public_note), + random_minimum_amount: None, } .into(), )) @@ -368,6 +372,7 @@ impl TokenMintTransitionActionV0 { mint_amount: *amount, identity_balance_holder_id, public_note: change_note.unwrap_or(public_note.clone()), + random_minimum_amount: None, } .into(), )) @@ -375,4 +380,113 @@ impl TokenMintTransitionActionV0 { fee_result, )) } + + /// Converts a `TokenMintTransitionV1` into a `TokenMintTransitionActionV0`: the version 0 + /// conversion of its version 0 fields, the mint action it yields carrying the random + /// minimum amount. + /// + /// # Arguments + /// + /// The arguments of [`Self::try_from_token_mint_transition_with_contract_lookup`], with + /// `value` a `TokenMintTransitionV1`. + /// + /// # Returns + /// + /// * `Result<(ConsensusValidationResult, FeeResult), Error>` - The + /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made. + #[allow(clippy::too_many_arguments)] + pub fn try_from_token_mint_transition_v1_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: TokenMintTransitionV1, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + let (v0, random_minimum_amount) = value.into_v0_and_random_minimum_amount(); + let (mut result, fee_result) = Self::try_from_token_mint_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + )?; + set_random_minimum_amount(&mut result, random_minimum_amount); + Ok((result, fee_result)) + } + + /// Converts a borrowed `TokenMintTransitionV1` into a `TokenMintTransitionActionV0`: the + /// version 0 conversion of its version 0 fields, the mint action it yields carrying the + /// random minimum amount. + /// + /// # Arguments + /// + /// The arguments of [`Self::try_from_borrowed_token_mint_transition_with_contract_lookup`], + /// with `value` a borrowed `TokenMintTransitionV1`. + /// + /// # Returns + /// + /// * `Result<(ConsensusValidationResult, FeeResult), Error>` - The + /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_mint_transition_v1_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenMintTransitionV1, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + let (v0, random_minimum_amount) = value.clone().into_v0_and_random_minimum_amount(); + let (mut result, fee_result) = + Self::try_from_borrowed_token_mint_transition_with_contract_lookup( + drive, + owner_id, + &v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + )?; + set_random_minimum_amount(&mut result, random_minimum_amount); + Ok((result, fee_result)) + } +} + +/// Sets `random_minimum_amount` on the mint action `result` holds; a refused mint holds a nonce +/// bump instead, which has none. +fn set_random_minimum_amount( + result: &mut ConsensusValidationResult, + random_minimum_amount: Option, +) { + if let Some(BatchedTransitionAction::TokenAction(TokenTransitionAction::MintAction( + TokenMintTransitionAction::V0(mint), + ))) = result.data.as_mut() + { + mint.random_minimum_amount = random_minimum_amount; + } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/mod.rs index eb9fc965198..df82428e520 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/mod.rs @@ -1,6 +1,7 @@ use derive_more::From; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; +use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; use dpp::prelude::{DerivationEncryptionKeyIndex, RootEncryptionKeyIndex}; use dpp::tokens::{PrivateEncryptedNote, SharedEncryptedNote}; @@ -44,6 +45,12 @@ impl TokenTransferTransitionActionAccessorsV0 for TokenTransferTransitionAction } } + fn random_minimum_amount(&self) -> Option { + match self { + TokenTransferTransitionAction::V0(v0) => v0.random_minimum_amount(), + } + } + fn public_note(&self) -> Option<&String> { match self { TokenTransferTransitionAction::V0(v0) => v0.public_note(), diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/transformer.rs index 950121f112b..a4e069482e5 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/transformer.rs @@ -60,6 +60,19 @@ impl TokenTransferTransitionAction { )?; Ok((v0.into(), fee)) } + TokenTransferTransition::V1(v1) => { + let (v0, fee) = TokenTransferTransitionActionV0::try_from_token_transfer_transition_v1_with_contract_lookup( + drive, + owner_id, + v1, + approximate_without_state_for_costs, + transaction, + block_info, + get_data_contract, + platform_version, + )?; + Ok((v0.into(), fee)) + } } } @@ -109,6 +122,19 @@ impl TokenTransferTransitionAction { )?; Ok((v0.into(), fee)) } + TokenTransferTransition::V1(v1) => { + let (v0, fee) = TokenTransferTransitionActionV0::try_from_borrowed_token_transfer_transition_v1_with_contract_lookup( + drive, + owner_id, + v1, + approximate_without_state_for_costs, + transaction, + block_info, + get_data_contract, + platform_version, + )?; + Ok((v0.into(), fee)) + } } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/mod.rs index b864a9748eb..7bb741753e4 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/mod.rs @@ -2,6 +2,7 @@ mod transformer; use std::sync::Arc; +use dpp::balances::credits::TokenAmount; use dpp::identifier::Identifier; use dpp::prelude::{ DerivationEncryptionKeyIndex, IdentityNonce, RootEncryptionKeyIndex, @@ -17,7 +18,8 @@ use crate::state_transition_action::batch::batched_transition::token_transition: pub struct TokenTransferTransitionActionV0 { /// Base token transition action pub base: TokenBaseTransitionAction, - /// The amount to transfer + /// The amount to transfer: with `random_minimum_amount` set, the most it transfers, taken + /// from the sender when the block executes it pub amount: u64, /// The recipient owner ID pub recipient_id: Identifier, @@ -31,6 +33,10 @@ pub struct TokenTransferTransitionActionV0 { DerivationEncryptionKeyIndex, Vec, )>, + /// The least the transfer moves when its amount is random (protocol version 15): the + /// recipient receives an amount drawn from this to `amount` from the block's random beacon, + /// and the rest goes back to the sender (`Drive::settle_random_token_amounts`) + pub random_minimum_amount: Option, } /// Accessors for `TokenTransferTransitionActionV0` @@ -47,6 +53,10 @@ pub trait TokenTransferTransitionActionAccessorsV0 { /// Returns the recipient owner ID fn recipient_id(&self) -> Identifier; + /// The least the transfer moves when its amount is random, `None` for a transfer of + /// exactly `amount` + fn random_minimum_amount(&self) -> Option; + /// Returns the token position in the contract fn token_position(&self) -> u16 { self.base().token_position() @@ -151,6 +161,10 @@ impl TokenTransferTransitionActionAccessorsV0 for TokenTransferTransitionActionV self.recipient_id } + fn random_minimum_amount(&self) -> Option { + self.random_minimum_amount + } + fn public_note(&self) -> Option<&String> { self.public_note.as_ref() } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs index 64739f3d3a9..74e4e33fd62 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs @@ -1,5 +1,6 @@ use dpp::identifier::Identifier; use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; +use dpp::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV1; use dpp::ProtocolError; use grovedb::TransactionArg; use std::sync::Arc; @@ -91,6 +92,7 @@ impl TokenTransferTransitionActionV0 { public_note, shared_encrypted_note, private_encrypted_note, + random_minimum_amount: None, }, fee_result, )) @@ -175,10 +177,90 @@ impl TokenTransferTransitionActionV0 { public_note: public_note.clone(), shared_encrypted_note: shared_encrypted_note.clone(), private_encrypted_note: private_encrypted_note.clone(), + random_minimum_amount: None, }, fee_result, )) } + + /// Converts a `TokenTransferTransitionV1` into a `TokenTransferTransitionActionV0`: the + /// version 0 conversion of its version 0 fields, carrying its random minimum amount. + /// + /// # Arguments + /// + /// The arguments of [`Self::try_from_token_transfer_transition_with_contract_lookup`], with + /// `value` a `TokenTransferTransitionV1`. + /// + /// # Returns + /// + /// * `Result<(TokenTransferTransitionActionV0, FeeResult), Error>` - The action and the fees + /// of the lookups it made. + #[allow(clippy::too_many_arguments)] + pub fn try_from_token_transfer_transition_v1_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: TokenTransferTransitionV1, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result<(Self, FeeResult), Error> { + let (v0, random_minimum_amount) = value.into_v0_and_random_minimum_amount(); + let (mut action, fee_result) = + Self::try_from_token_transfer_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + get_data_contract, + platform_version, + )?; + action.random_minimum_amount = random_minimum_amount; + Ok((action, fee_result)) + } + + /// Converts a borrowed `TokenTransferTransitionV1` into a `TokenTransferTransitionActionV0`: + /// the version 0 conversion of its version 0 fields, carrying its random minimum amount. + /// + /// # Arguments + /// + /// The arguments of + /// [`Self::try_from_borrowed_token_transfer_transition_with_contract_lookup`], with `value` + /// a borrowed `TokenTransferTransitionV1`. + /// + /// # Returns + /// + /// * `Result<(TokenTransferTransitionActionV0, FeeResult), Error>` - The action and the fees + /// of the lookups it made. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_transfer_transition_v1_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenTransferTransitionV1, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result<(Self, FeeResult), Error> { + let (v0, random_minimum_amount) = value.clone().into_v0_and_random_minimum_amount(); + let (mut action, fee_result) = + Self::try_from_borrowed_token_transfer_transition_with_contract_lookup( + drive, + owner_id, + &v0, + approximate_without_state_for_costs, + transaction, + block_info, + get_data_contract, + platform_version, + )?; + action.random_minimum_amount = random_minimum_amount; + Ok((action, fee_result)) + } } #[cfg(test)] @@ -236,6 +318,7 @@ mod tests { public_note: public_note.map(|s| s.to_string()), shared_encrypted_note: shared, private_encrypted_note: private_, + random_minimum_amount: None, } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs index d0de2956eb0..7a8b0ccfd40 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs @@ -122,6 +122,7 @@ mod tests { mint_amount: 100, identity_balance_holder_id: Identifier::new([3u8; 32]), public_note: None, + random_minimum_amount: None, }, )); assert_eq!(action.action_type(), TokenTransitionActionType::Mint); @@ -137,6 +138,7 @@ mod tests { public_note: None, shared_encrypted_note: None, private_encrypted_note: None, + random_minimum_amount: None, }, )); assert_eq!(action.action_type(), TokenTransitionActionType::Transfer); @@ -281,6 +283,7 @@ mod tests { mint_amount: 1, identity_balance_holder_id: Identifier::new([3u8; 32]), public_note: None, + random_minimum_amount: None, }, )), TokenTransitionAction::TransferAction(TokenTransferTransitionAction::V0( @@ -291,6 +294,7 @@ mod tests { public_note: None, shared_encrypted_note: None, private_encrypted_note: None, + random_minimum_amount: None, }, )), TokenTransitionAction::FreezeAction(TokenFreezeTransitionAction::V0( diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index 5137621e45e..febe0646730 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -1004,6 +1004,7 @@ fn make_mint_v0() -> TokenMintTransitionActionV0 { mint_amount: 1000, identity_balance_holder_id: Identifier::from([0xDD; 32]), public_note: Some("mint note".to_string()), + random_minimum_amount: None, } } @@ -1140,6 +1141,7 @@ fn make_token_transfer_v0() -> TokenTransferTransitionActionV0 { public_note: Some("transfer note".to_string()), shared_encrypted_note: None, private_encrypted_note: None, + random_minimum_amount: None, } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index b9d3d4c1051..97291777b6c 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -634,6 +634,27 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { TokenBalanceKey::Holder(*token_id, *recipient_id), TokenBalanceKey::PoolTotal(*token_id), ], + // Reserving a random transfer moves the most it moves from the sender into the token's + // escrow, and reserving a random mint raises the total supply by it and holds it in + // the escrow; both write the recipient's balance unchanged. The settlement writes the + // rest once the block's beacon is known. + TokenOperationType::TokenReserveRandomTransfer { + token_id, + sender_id, + recipient_id, + .. + } => vec![ + TokenBalanceKey::Holder(*token_id, *sender_id), + TokenBalanceKey::Holder(*token_id, *recipient_id), + ], + TokenOperationType::TokenReserveRandomMint { + token_id, + recipient_id, + .. + } => vec![ + TokenBalanceKey::Holder(*token_id, *recipient_id), + TokenBalanceKey::Supply(*token_id), + ], // Minting into the pool raises the total supply and the pool total by the same amount // and burning from it lowers both; neither touches an identity's balance. TokenOperationType::TokenMintToPool { token_id, .. } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index 99ff837d42f..1ef11021aee 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -1,4 +1,5 @@ use crate::util::estimated_layers::EstimatedLayers; +use crate::drive::tokens::settle_random_token_amounts::random_token_reservation_id; use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; @@ -190,6 +191,44 @@ pub enum TokenOperationType { /// The change notes of the bundle notes: Vec, }, + /// Reserves the most a random token transfer moves (protocol version 15): moves `maximum` + /// from the sender's balance into the token marketplace escrow of the token, under the + /// transfer's reservation id (`random_token_reservation_id`), and writes the recipient's + /// balance unchanged, creating it at zero if it has none, so the transition pays for its + /// storage. `Drive::settle_random_token_amounts` later removes the reservation, pays the + /// drawn amount to the recipient and the rest back to the sender. + TokenReserveRandomTransfer { + /// The token id + token_id: Identifier, + /// The identity sending the tokens, the transition's owner + sender_id: Identifier, + /// The identity receiving the drawn amount + recipient_id: Identifier, + /// The identity contract nonce of the transition, which keys the reservation + identity_contract_nonce: IdentityNonce, + /// The most the transfer moves + maximum: TokenAmount, + }, + /// Reserves the most a random token mint issues (protocol version 15): adds `maximum` to + /// the token's total supply and holds it in the token marketplace escrow of the token, + /// under the mint's reservation id (`random_token_reservation_id`), and writes the + /// recipient's balance unchanged, creating it at zero if it has none, so the transition pays + /// for its storage. `Drive::settle_random_token_amounts` later removes the reservation, pays + /// the drawn amount to the recipient and takes the rest off the total supply. + TokenReserveRandomMint { + /// The token id + token_id: Identifier, + /// The identity minting, the transition's owner + owner_id: Identifier, + /// The identity receiving the drawn amount + recipient_id: Identifier, + /// The identity contract nonce of the transition, which keys the reservation + identity_contract_nonce: IdentityNonce, + /// The most the mint issues + maximum: TokenAmount, + /// Should we allow this to be the first ever mint + allow_first_mint: bool, + }, } impl DriveLowLevelOperationConverter for TokenOperationType { @@ -457,10 +496,123 @@ impl DriveLowLevelOperationConverter for TokenOperationType { transaction, platform_version, ), + TokenOperationType::TokenReserveRandomTransfer { + token_id, + sender_id, + recipient_id, + identity_contract_nonce, + maximum, + } => { + let token_id_bytes = token_id.to_buffer(); + let mut batch_operations = drive.remove_from_identity_token_balance_operations( + token_id_bytes, + sender_id.to_buffer(), + maximum, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + // The recipient's balance as it stands, created at zero if it has none: the + // transition pays for it, and the settlement only replaces its value + batch_operations.extend(drive.add_to_identity_token_balance_operations( + token_id_bytes, + recipient_id.to_buffer(), + 0, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + batch_operations.extend(random_token_reservation_operations( + drive, + token_id, + sender_id, + identity_contract_nonce, + maximum, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + Ok(batch_operations) + } + TokenOperationType::TokenReserveRandomMint { + token_id, + owner_id, + recipient_id, + identity_contract_nonce, + maximum, + allow_first_mint, + } => { + let token_id_bytes = token_id.to_buffer(); + // No saturation: the mint's state validation made room for `maximum` under the + // token's maximum supply, so all of it is reserved, and the settlement takes + // off what the draw leaves out of exactly that + let (mut batch_operations, _) = drive.add_to_token_total_supply_operations( + token_id_bytes, + maximum, + allow_first_mint, + false, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + // The recipient's balance as it stands, created at zero if it has none: the + // transition pays for it, and the settlement only replaces its value + batch_operations.extend(drive.add_to_identity_token_balance_operations( + token_id_bytes, + recipient_id.to_buffer(), + 0, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + batch_operations.extend(random_token_reservation_operations( + drive, + token_id, + owner_id, + identity_contract_nonce, + maximum, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + Ok(batch_operations) + } } } } +/// The operations holding `maximum` of `token_id` in the token marketplace escrow of the token, +/// under the reservation id of the random transfer or mint `owner_id` sent with +/// `identity_contract_nonce`, creating the token's escrow tree if it has none. +/// +/// The entry lives from the execution of the transition to the settlement of the block's random +/// token amounts, which removes it in the same block, so a marketplace read of committed state +/// never sees it; a read during the block finds no listing under its key, which the domain of +/// the reservation id keeps apart from every order id. The token conservation check counts the +/// escrow, so it balances while the entry exists. A batch carries one transition, so no +/// operation queued before this one in the batch creates the token's escrow tree. +#[allow(clippy::too_many_arguments)] +fn random_token_reservation_operations( + drive: &Drive, + token_id: Identifier, + owner_id: Identifier, + identity_contract_nonce: IdentityNonce, + maximum: TokenAmount, + estimated_costs_only_with_layer_info: &mut Option, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> { + drive.add_to_token_order_escrow_operations( + token_id.to_buffer(), + random_token_reservation_id(&token_id, &owner_id, identity_contract_nonce), + maximum, + estimated_costs_only_with_layer_info, + transaction, + &mut None, + platform_version, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -820,6 +972,37 @@ mod tests { } } + #[test] + fn should_construct_the_random_reservations() { + let op = TokenOperationType::TokenReserveRandomTransfer { + token_id: test_token_id(), + sender_id: test_identity_id(), + recipient_id: test_recipient_id(), + identity_contract_nonce: 3, + maximum: 900, + }; + assert!(matches!( + op, + TokenOperationType::TokenReserveRandomTransfer { maximum: 900, .. } + )); + let op = TokenOperationType::TokenReserveRandomMint { + token_id: test_token_id(), + owner_id: test_identity_id(), + recipient_id: test_recipient_id(), + identity_contract_nonce: 3, + maximum: 400, + allow_first_mint: false, + }; + assert!(matches!( + op, + TokenOperationType::TokenReserveRandomMint { + maximum: 400, + allow_first_mint: false, + .. + } + )); + } + #[test] fn test_token_operation_debug() { let op = TokenOperationType::TokenBurn { diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index c0ab82e2d34..f7c0e440152 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -679,9 +679,33 @@ impl Drive { platform_version, )?; + // A random transfer or mint (protocol version 15) moves an amount + // its sender cannot know: the history holds the amount drawn from + // the block's random beacon (the range's minimum until the draw), + // so the amount is taken from the proved document once it lies + // in the range the transition asked for. A transition without a + // random amount compares it as before. + let random_amount_range = match token_transition { + TokenTransition::Transfer(transfer) => transfer + .random_minimum_amount() + .map(|minimum| minimum..=transfer.amount()), + TokenTransition::Mint(mint) => mint + .random_minimum_amount() + .map(|minimum| minimum..=mint.amount()), + _ => None, + }; + if let Some(range) = &random_amount_range { + let amount: TokenAmount = + document.properties().get_integer("amount")?; + if !range.contains(&amount) { + return Err(Error::Proof(ProofError::UnexpectedResultProof(format!("proof of state transition execution showed an amount of {}, outside the random range {} to {} the state transition asked for, state transition is [{}]", amount, range.start(), range.end(), token_transition)))); + } + } + // Some fields are populated by the drive, // so we need to ignore them let ignore_fields = match token_transition { + _ if random_amount_range.is_some() => Some(vec!["amount"]), TokenTransition::DestroyFrozenFunds(_) => { Some(vec!["destroyedAmount", "note"]) } diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs index 650f5adbce7..0ca426e0118 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs @@ -14,6 +14,11 @@ pub struct DriveTokenMethodVersions { /// The escrow of the token marketplace (protocol version 15): the tokens open listings hold /// and the credits open offers hold. pub order_escrow: DriveTokenOrderEscrowMethodVersions, + /// `settle_random_token_amounts`: pays out the random token transfers and mints of a block + /// from the block's random beacon, once every transition of the block is accepted. + /// `Some(0)` from `DRIVE_TOKEN_METHOD_VERSIONS_V3` (protocol version 15), `None` in every + /// earlier table, whose protocol versions decode no random token amount. + pub settle_random_token_amounts: OptionalFeatureVersion, } /// The escrow methods of the token marketplace: `None` in every table before protocol version diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs index 7f3280a7831..5f812a58ce5 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v1.rs @@ -84,4 +84,7 @@ pub const DRIVE_TOKEN_METHOD_VERSIONS_V1: DriveTokenMethodVersions = DriveTokenM estimated_costs_for_tokens: None, estimated_costs_for_credits: None, }, + // Random token amounts are settled from protocol version 15 + // (`DRIVE_TOKEN_METHOD_VERSIONS_V3`). + settle_random_token_amounts: None, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs index 84a628706b2..aa620985bc4 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v3.rs @@ -8,8 +8,9 @@ use crate::version::drive_versions::drive_token_method_versions::{ /// Identical to [`super::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2`] except /// `calculate_total_tokens_balance` is bumped to `2`: the tokens the token marketplace holds in /// escrow for open listings join the identity balances and the shielded pools on the balance -/// side of the token conservation check; and the token marketplace's escrow methods, `None` -/// before, are active. +/// side of the token conservation check; the token marketplace's escrow methods, `None` +/// before, are active; and `settle_random_token_amounts`, `None` before, pays out the random +/// token transfers and mints of a block. pub const DRIVE_TOKEN_METHOD_VERSIONS_V3: DriveTokenMethodVersions = DriveTokenMethodVersions { calculate_total_tokens_balance: 2, order_escrow: DriveTokenOrderEscrowMethodVersions { @@ -24,5 +25,6 @@ pub const DRIVE_TOKEN_METHOD_VERSIONS_V3: DriveTokenMethodVersions = DriveTokenM estimated_costs_for_tokens: Some(0), estimated_costs_for_credits: Some(0), }, + settle_random_token_amounts: Some(0), // changed: random token amounts are settled ..DRIVE_TOKEN_METHOD_VERSIONS_V2 }; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 3aface1463c..29b0360edea 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -32,6 +32,9 @@ use crate::version::drive_versions::{ /// * **Token conservation**: `DRIVE_TOKEN_METHOD_VERSIONS_V3` bumps /// `calculate_total_tokens_balance` to 2, which counts the escrowed listings on the balance /// side; its escrow methods, `None` before, are active. +/// * **Random token amounts**: `DRIVE_TOKEN_METHOD_VERSIONS_V3` makes +/// `settle_random_token_amounts` `Some(0)` (`None` before): the random token transfers and +/// mints of a block are paid out from the block's random beacon. /// * **Escrow fee estimates**: the offers' escrow key sits a level below the other keys of the /// prefunded specialized balances layer, so `estimated_cost_for_prefunded_specialized_balance_update` /// 2 and `add_estimation_costs_for_contract_fee_pot_update` 1 keep a deeper description of that @@ -79,7 +82,7 @@ pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { initialization: DriveInitializationMethodVersions { create_initial_state_structure: 5, // changed in v10: adds the token marketplace escrow trees }, - token: DRIVE_TOKEN_METHOD_VERSIONS_V3, // changed in v10: calculate_total_tokens_balance 2 counts the token marketplace escrow; order_escrow active + token: DRIVE_TOKEN_METHOD_VERSIONS_V3, // changed in v10: calculate_total_tokens_balance 2 counts the token marketplace escrow; order_escrow active; settle_random_token_amounts Some(0) contract: DriveContractMethodVersions { fee_pots: DriveContractFeePotMethodVersions { add_estimation_costs_for_contract_fee_pot_update: 1, // changed in v10: keeps a deeper prefunded balances layer description diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index d97da1c9594..ebc1ccfe434 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -535,6 +535,14 @@ fn summarize_token_transition( let mut lines = Vec::new(); let (amount, recipient_id, token_count, public_note) = match transition { TokenTransition::Transfer(t) => { + // `amount` is then the most the transfer moves, which the sender must hold + if let Some(minimum) = t.random_minimum_amount() { + lines.push(budget.write(format_args!( + "random amount: from {} to {}", + minimum, + t.amount() + ))?); + } if t.shared_encrypted_note().is_some() { lines.push(budget.write(format_args!("shared encrypted note: present"))?); } @@ -549,6 +557,14 @@ fn summarize_token_transition( ) } TokenTransition::Mint(t) => { + // `amount` is then the most the mint issues + if let Some(minimum) = t.random_minimum_amount() { + lines.push(budget.write(format_args!( + "random amount: from {} to {}", + minimum, + t.amount() + ))?); + } if t.issued_to_identity_id().is_none() { lines.push( budget.write(format_args!("issued to: the token's default destination"))?, @@ -669,6 +685,7 @@ mod tests { use dpp::state_transition::batch_transition::token_emergency_action_transition::v0::TokenEmergencyActionTransitionV0; use dpp::state_transition::batch_transition::token_set_price_for_direct_purchase_transition::v0::TokenSetPriceForDirectPurchaseTransitionV0; use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; + use dpp::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV1; use dpp::state_transition::batch_transition::{ BatchTransitionV1, DocumentCreateTransition, TokenClaimTransition, TokenConfigUpdateTransition, TokenDirectPurchaseTransition, @@ -1521,6 +1538,32 @@ mod tests { } } + /// A transfer of a random amount shows the range the signer agrees to: the amount it + /// names is only the most it can move. + #[test] + fn should_show_the_range_of_a_random_transfer() { + let summary = summarize_bytes(&batch(vec![BatchedTransition::Token( + TokenTransition::Transfer(TokenTransferTransition::V1(TokenTransferTransitionV1 { + base: token_base(), + amount: 250, + recipient_id: Identifier::from(RECIPIENT), + public_note: None, + shared_encrypted_note: None, + private_encrypted_note: None, + random_minimum_amount: Some(40), + })), + )])); + let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { + panic!("expected a batch"); + }; + assert_eq!(transitions[0].amount, Some(250)); + let details = transitions[0].details.as_deref().expect("range rendered"); + assert!( + details.contains("random amount: from 40 to 250"), + "{details}" + ); + } + /// A public note is quoted, so a newline cannot forge another line and a /// NUL cannot break the C projection. #[test] diff --git a/packages/rs-sdk-ffi/src/document/helpers.rs b/packages/rs-sdk-ffi/src/document/helpers.rs index 491da439c49..badbb8b8389 100644 --- a/packages/rs-sdk-ffi/src/document/helpers.rs +++ b/packages/rs-sdk-ffi/src/document/helpers.rs @@ -170,6 +170,8 @@ pub unsafe fn convert_state_transition_creation_options( } else { Some(options.contest_fund) }, + // Only a token transfer or mint reads it + random_minimum_amount: None, }) } diff --git a/packages/rs-sdk-ffi/src/identity/helpers.rs b/packages/rs-sdk-ffi/src/identity/helpers.rs index de1442a4bd3..8708c440622 100644 --- a/packages/rs-sdk-ffi/src/identity/helpers.rs +++ b/packages/rs-sdk-ffi/src/identity/helpers.rs @@ -62,6 +62,7 @@ pub unsafe fn convert_put_settings(put_settings: *const DashSDKPutSettings) -> O base_feature_version: None, action_fee_agreement: None, contest_fund: None, + random_minimum_amount: None, }); let wait_timeout = if ios_settings.wait_timeout_ms > 0 { diff --git a/packages/rs-sdk-ffi/src/token/utils.rs b/packages/rs-sdk-ffi/src/token/utils.rs index b628c87b35b..6b959f32e76 100644 --- a/packages/rs-sdk-ffi/src/token/utils.rs +++ b/packages/rs-sdk-ffi/src/token/utils.rs @@ -45,6 +45,8 @@ pub unsafe fn convert_state_transition_creation_options( action_fee_agreement: None, // A token transition joins no contest, so `options.contest_fund` is not read contest_fund: None, + // The FFI offers no random token amount yet + random_minimum_amount: None, }) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/mint.rs b/packages/rs-sdk/src/platform/tokens/builders/mint.rs index 350032f4119..e60c52e476a 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mint.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mint.rs @@ -28,6 +28,9 @@ pub struct TokenMintTransitionBuilder { pub user_fee_increase: Option, pub using_group_info: Option, pub state_transition_creation_options: Option, + /// The least the mint issues when its amount is random (protocol version 15); see + /// [`Self::with_random_minimum_amount`] + pub random_minimum_amount: Option, } impl TokenMintTransitionBuilder { @@ -60,6 +63,7 @@ impl TokenMintTransitionBuilder { user_fee_increase: None, using_group_info: None, state_transition_creation_options: None, + random_minimum_amount: None, } } @@ -156,6 +160,30 @@ impl TokenMintTransitionBuilder { self } + /// Makes the mint issue a random amount (protocol version 15): the recipient receives an + /// amount drawn uniformly from `random_minimum_amount` to the builder's amount, both + /// included, from the random beacon of the block that carries the mint. The token's + /// maximum supply must have room for the builder's amount, which the block adds to the + /// total supply before the draw; what the draw leaves is taken off again. Nobody knows the + /// amount before the block is accepted: read it from the proof of the transition, the mint + /// history document when the token keeps one. + /// + /// The transition is built in format version 1, which no protocol version before 15 + /// accepts. Structure validation requires the minimum to be at least 1 and below the + /// amount, a recipient set with [`Self::issued_to_identity_id`], and no group action. + /// + /// # Arguments + /// + /// * `random_minimum_amount` - The least the mint issues + /// + /// # Returns + /// + /// * `Self` - The updated builder + pub fn with_random_minimum_amount(mut self, random_minimum_amount: TokenAmount) -> Self { + self.random_minimum_amount = Some(random_minimum_amount); + self + } + /// Signs the token mint transition /// /// # Arguments @@ -180,6 +208,15 @@ impl TokenMintTransitionBuilder { self.token_position, )); + // The random minimum amount rides in the creation options, which the batch builder reads + let state_transition_creation_options = match self.random_minimum_amount { + Some(random_minimum_amount) => Some(StateTransitionCreationOptions { + random_minimum_amount: Some(random_minimum_amount), + ..self.state_transition_creation_options.unwrap_or_default() + }), + None => self.state_transition_creation_options, + }; + let identity_contract_nonce = sdk .get_identity_contract_nonce( self.issuer_id, @@ -203,7 +240,7 @@ impl TokenMintTransitionBuilder { self.user_fee_increase.unwrap_or_default(), signer, platform_version, - self.state_transition_creation_options, + state_transition_creation_options, ) .await?; diff --git a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs index a8e93f54be6..414a26ccc58 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs @@ -28,6 +28,9 @@ pub struct TokenTransferTransitionBuilder { pub settings: Option, pub user_fee_increase: Option, pub state_transition_creation_options: Option, + /// The least the transfer moves when its amount is random (protocol version 15); see + /// [`Self::with_random_minimum_amount`] + pub random_minimum_amount: Option, } impl TokenTransferTransitionBuilder { @@ -63,6 +66,7 @@ impl TokenTransferTransitionBuilder { private_encrypted_note: None, shared_encrypted_note: None, state_transition_creation_options: None, + random_minimum_amount: None, } } @@ -160,6 +164,30 @@ impl TokenTransferTransitionBuilder { self } + /// Makes the transfer move a random amount (protocol version 15): the recipient receives + /// an amount drawn uniformly from `random_minimum_amount` to the builder's amount, both + /// included, from the random beacon of the block that carries the transfer. The sender + /// must hold the builder's amount, which the block takes before the draw; what the draw + /// leaves goes back to the sender. Nobody knows the amount before the block is accepted: + /// read it from the proof of the transition, the transfer history document when the token + /// keeps one. + /// + /// The transition is built in format version 1, which no protocol version before 15 + /// accepts. Structure validation requires the minimum to be at least 1 and below the + /// amount. + /// + /// # Arguments + /// + /// * `random_minimum_amount` - The least the transfer moves + /// + /// # Returns + /// + /// * `Self` - The updated builder + pub fn with_random_minimum_amount(mut self, random_minimum_amount: TokenAmount) -> Self { + self.random_minimum_amount = Some(random_minimum_amount); + self + } + /// Signs the token transfer transition /// /// # Arguments @@ -184,6 +212,15 @@ impl TokenTransferTransitionBuilder { self.token_position, )); + // The random minimum amount rides in the creation options, which the batch builder reads + let state_transition_creation_options = match self.random_minimum_amount { + Some(random_minimum_amount) => Some(StateTransitionCreationOptions { + random_minimum_amount: Some(random_minimum_amount), + ..self.state_transition_creation_options.unwrap_or_default() + }), + None => self.state_transition_creation_options, + }; + let identity_contract_nonce = sdk .get_identity_contract_nonce( self.issuer_id, @@ -208,7 +245,7 @@ impl TokenTransferTransitionBuilder { self.user_fee_increase.unwrap_or_default(), signer, platform_version, - self.state_transition_creation_options, + state_transition_creation_options, ) .await?; diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 2a5af4a1035..f260c65f182 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -71,7 +71,7 @@ use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedErr use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; use dpp::consensus::basic::overflow_error::OverflowError; -use dpp::consensus::basic::token::{ChoosingTokenMintRecipientNotAllowedError, ContractHasNoTokensError, DestinationIdentityForTokenMintingNotSetError, InvalidActionIdError, InvalidTokenAmountError, InvalidTokenConfigUpdateNoChangeError, InvalidTokenIdError, InvalidTokenNoteTooBigError, InvalidTokenPositionError, MissingDefaultLocalizationError, TokenNoteOnlyAllowedWhenProposerError, TokenPricingScheduleEmptyError, TokenTransferToOurselfError, InvalidTokenDistributionTimeIntervalNotMinuteAlignedError, InvalidTokenDistributionTimeIntervalTooShortError, InvalidTokenDistributionBlockIntervalTooShortError, InvalidTokenDistributionEpochIntervalTooShortError, InvalidTokenOncePerIdentityDistributionAmountError}; +use dpp::consensus::basic::token::{ChoosingTokenMintRecipientNotAllowedError, ContractHasNoTokensError, DestinationIdentityForTokenMintingNotSetError, InvalidActionIdError, InvalidTokenAmountError, InvalidTokenConfigUpdateNoChangeError, InvalidTokenIdError, InvalidTokenNoteTooBigError, InvalidTokenPositionError, MissingDefaultLocalizationError, RandomTokenMintNotAllowedError, TokenNoteOnlyAllowedWhenProposerError, TokenPricingScheduleEmptyError, TokenTransferToOurselfError, InvalidTokenDistributionTimeIntervalNotMinuteAlignedError, InvalidTokenDistributionTimeIntervalTooShortError, InvalidTokenDistributionBlockIntervalTooShortError, InvalidTokenDistributionEpochIntervalTooShortError, InvalidTokenOncePerIdentityDistributionAmountError}; use dpp::consensus::state::data_contract::data_contract_not_found_error::DataContractNotFoundError; use dpp::consensus::state::data_contract::data_contract_update_action_not_allowed_error::DataContractUpdateActionNotAllowedError; use dpp::consensus::state::data_contract::document_type_update_error::DocumentTypeUpdateError; @@ -1451,6 +1451,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::DocumentTypePrivateError(e) => { generic_consensus_error!(DocumentTypePrivateError, e).into() } + BasicError::RandomTokenMintNotAllowedError(e) => { + generic_consensus_error!(RandomTokenMintNotAllowedError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_mint.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_mint.rs index bf6c796508b..00bb835604f 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_mint.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_mint.rs @@ -14,6 +14,7 @@ use dpp::state_transition::batch_transition::token_mint_transition::v0::v0_metho use dpp::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV0; use dpp::state_transition::batch_transition::TokenMintTransition; use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; #[wasm_bindgen(typescript_custom_section)] const TOKEN_MINT_OPTIONS_TS: &str = r#" @@ -22,6 +23,12 @@ export interface TokenMintTransitionOptions { amount: bigint; issuedToIdentityId?: IdentifierLike; publicNote?: string; + /** + * The least the mint issues when its amount is random (protocol version 15): the + * recipient receives an amount drawn from it to `amount`. Setting it makes a version 1 + * mint, which no protocol version before 15 accepts. + */ + randomMinimumAmount?: bigint; } "#; @@ -65,14 +72,20 @@ impl TokenMintTransitionWasm { try_to_string(v, "publicNote") })?; - Ok(TokenMintTransitionWasm(TokenMintTransition::V0( - TokenMintTransitionV0 { - base: base.into(), - issued_to_identity_id: issued_to_identity_id.map(Into::into), - amount, - public_note, - }, - ))) + let random_minimum_amount: Option = + try_from_options_optional_with(&options, "randomMinimumAmount", |v| { + try_to_u64(v, "randomMinimumAmount") + })?; + + let mut transition = TokenMintTransition::V0(TokenMintTransitionV0 { + base: base.into(), + issued_to_identity_id: issued_to_identity_id.map(Into::into), + amount, + public_note, + }); + // A random amount takes the version 1 format + transition.set_random_minimum_amount(random_minimum_amount); + Ok(TokenMintTransitionWasm(transition)) } #[wasm_bindgen(getter = issuedToIdentityId)] @@ -85,6 +98,11 @@ impl TokenMintTransitionWasm { self.0.amount() } + #[wasm_bindgen(getter = randomMinimumAmount)] + pub fn random_minimum_amount(&self) -> Option { + self.0.random_minimum_amount() + } + #[wasm_bindgen(getter = base)] pub fn base(&self) -> TokenBaseTransitionWasm { self.0.base().clone().into() @@ -116,6 +134,21 @@ impl TokenMintTransitionWasm { Ok(()) } + #[wasm_bindgen(setter = randomMinimumAmount)] + pub fn set_random_minimum_amount( + &mut self, + #[wasm_bindgen(js_name = "randomMinimumAmount")] random_minimum_amount: JsValue, + ) -> WasmDppResult<()> { + let random_minimum_amount = + if random_minimum_amount.is_undefined() || random_minimum_amount.is_null() { + None + } else { + Some(try_to_u64(&random_minimum_amount, "randomMinimumAmount")?) + }; + self.0.set_random_minimum_amount(random_minimum_amount); + Ok(()) + } + #[wasm_bindgen(setter = base)] pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { self.0.set_base(base.into()) diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_transfer.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_transfer.rs index a53f3627ab2..b849b0ec5a5 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_transfer.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_transfer.rs @@ -26,6 +26,12 @@ export interface TokenTransferTransitionOptions { publicNote?: string; sharedEncryptedNote?: SharedEncryptedNote; privateEncryptedNote?: PrivateEncryptedNote; + /** + * The least the transfer moves when its amount is random (protocol version 15): the + * recipient receives an amount drawn from it to `amount`. Setting it makes a version 1 + * transfer, which no protocol version before 15 accepts. + */ + randomMinimumAmount?: bigint; } "#; @@ -74,16 +80,22 @@ impl TokenTransferTransitionWasm { let private_encrypted_note: Option = try_from_options_optional(&options, "privateEncryptedNote")?; - Ok(TokenTransferTransitionWasm(TokenTransferTransition::V0( - TokenTransferTransitionV0 { - base: base.into(), - recipient_id: recipient_id.into(), - amount, - public_note, - shared_encrypted_note: shared_encrypted_note.map(Into::into), - private_encrypted_note: private_encrypted_note.map(Into::into), - }, - ))) + let random_minimum_amount: Option = + try_from_options_optional_with(&options, "randomMinimumAmount", |v| { + try_to_u64(v, "randomMinimumAmount") + })?; + + let mut transition = TokenTransferTransition::V0(TokenTransferTransitionV0 { + base: base.into(), + recipient_id: recipient_id.into(), + amount, + public_note, + shared_encrypted_note: shared_encrypted_note.map(Into::into), + private_encrypted_note: private_encrypted_note.map(Into::into), + }); + // A random amount takes the version 1 format + transition.set_random_minimum_amount(random_minimum_amount); + Ok(TokenTransferTransitionWasm(transition)) } #[wasm_bindgen(getter = "amount")] @@ -91,6 +103,11 @@ impl TokenTransferTransitionWasm { self.0.amount() } + #[wasm_bindgen(getter = "randomMinimumAmount")] + pub fn random_minimum_amount(&self) -> Option { + self.0.random_minimum_amount() + } + #[wasm_bindgen(getter = "base")] pub fn base(&self) -> TokenBaseTransitionWasm { self.0.base().clone().into() @@ -137,6 +154,21 @@ impl TokenTransferTransitionWasm { Ok(()) } + #[wasm_bindgen(setter = "randomMinimumAmount")] + pub fn set_random_minimum_amount( + &mut self, + #[wasm_bindgen(js_name = "randomMinimumAmount")] random_minimum_amount: JsValue, + ) -> WasmDppResult<()> { + let random_minimum_amount = + if random_minimum_amount.is_undefined() || random_minimum_amount.is_null() { + None + } else { + Some(try_to_u64(&random_minimum_amount, "randomMinimumAmount")?) + }; + self.0.set_random_minimum_amount(random_minimum_amount); + Ok(()) + } + #[wasm_bindgen(setter = "base")] pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { self.0.set_base(base.into()) From 446c9655b00fcb429b214ee1aef8209384a46dfe Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 04:17:25 +0700 Subject: [PATCH 2/8] fix(platform): keep random token amounts inactive until settled, prepay their history index entry Review follow-ups: - `random_token_amount` (new, None in every serialization table) decides when a batch carrying a version 1 token transfer or mint is active: from the first protocol version whose table has it, none until the release that wires settlement in. The end-to-end tests move with that release. - A random transfer or mint of a token keeping history prepays Drive's estimated (average-layer) cost of the history document's `byAmount` entry the settlement may move (`Drive::prepay_random_token_history_entries`, sharing the document prepay's helper). Settlement stays unbilled. - The V1 transformer arms move the owned payload instead of cloning it twice. - Negative proof tests: an amount outside the requested range, another recipient or another note are refused; both boundaries are accepted. Co-Authored-By: Claude Opus 5.5 --- packages/rs-dpp/src/state_transition/mod.rs | 120 +++-- .../batch/tests/token/mod.rs | 1 - .../batch/tests/token/random_amount/mod.rs | 409 ----------------- .../draw_random_document_properties/v0/mod.rs | 66 ++- .../tokens/settle_random_token_amounts/mod.rs | 70 ++- .../settle_random_token_amounts/v0/mod.rs | 55 ++- .../v0/transformer.rs | 252 +++++++++-- .../v0/transformer.rs | 193 +++++++- .../v0/mod.rs | 3 + .../v0/random_amount_tests.rs | 421 ++++++++++++++++++ .../mod.rs | 10 +- .../v1.rs | 1 + .../v2.rs | 1 + .../v3.rs | 3 + .../drive_token_method_versions/mod.rs | 8 +- 15 files changed, 1089 insertions(+), 524 deletions(-) delete mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/random_amount/mod.rs create mode 100644 packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 83072b29b54..7b16731c6b5 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -20,7 +20,9 @@ use platform_serialization_derive::{ PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, PlatformSignable, }; use platform_version::version::feature_initial_protocol_versions::YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION; -use platform_version::version::{PlatformVersion, ProtocolVersion, ALL_VERSIONS, LATEST_VERSION}; +use platform_version::version::{ + PlatformVersion, ProtocolVersion, ALL_VERSIONS, LATEST_VERSION, PLATFORM_VERSIONS, +}; #[cfg(any( feature = "state-transition-signing", @@ -1149,6 +1151,29 @@ fn batch_carries_a_version_1_token_transfer_or_mint(batch_transition: &BatchTran }) } +/// The active range of a batch carrying a token transfer or a token mint of format version 1 +/// (a random amount), among `versions`, the protocol versions in order: from the first whose +/// table admits a random token amount (`random_token_amount` is `Some`), and `NO_VERSION` while +/// none does. The tables decide it, so the release that settles the amounts activates them by +/// setting the slot in its table alone. Every table of protocol version 14 and earlier keeps it +/// `None` (no binary of theirs decodes the format), so the start is never below 15. +fn random_token_amount_active_version_range( + versions: &[PlatformVersion], +) -> RangeInclusive { + versions + .iter() + .find(|platform_version| { + platform_version + .dpp + .state_transition_serialization_versions + .random_token_amount + .is_some() + }) + .map_or(NO_VERSION, |platform_version| { + platform_version.protocol_version..=LATEST_VERSION + }) +} + impl StateTransition { /// Decodes a transition from bytes that arrived from outside this node and refuses one /// whose version is not active at `platform_version`. Bytes left over after the transition @@ -1238,13 +1263,15 @@ impl StateTransition { active_version_range_for_contract(data_contract_update_transition.data_contract()) } StateTransition::Batch(batch_transition) => { - // Version 1 of the token transfer and of the token mint (a random amount) - // exists from protocol version 15. Binaries from before it cannot decode one, so - // an earlier version rejects the batch without charging, exactly as they do. - // Checked first: 15 is the later start, so it holds whatever else the batch - // carries. + // Version 1 of the token transfer and of the token mint (a random amount) is + // active from the first protocol version whose table admits a random token + // amount, and nowhere while none does: block execution does not settle the + // amounts yet, so a reservation would never be paid out. Binaries from before 15 + // cannot decode one, so an earlier version rejects the batch without charging, + // exactly as they do. Checked first: the range starts at 15 at the earliest, the + // later start, so it holds whatever else the batch carries. if batch_carries_a_version_1_token_transfer_or_mint(batch_transition) { - return 15..=LATEST_VERSION; + return random_token_amount_active_version_range(PLATFORM_VERSIONS); } // Version 2 of the document base (the action fee agreement) exists from // protocol version 14. Binaries from before it cannot decode one, so an @@ -3622,13 +3649,13 @@ mod tests { assert_eq!(recovered, version_2); } - // Version 1 of the token transfer and of the token mint carries a random amount, which - // exists from protocol version 15. Software from before it cannot decode either, so new - // software must refuse the batch while an earlier version is active; version 0 stays - // active everywhere its batch is. + // Version 1 of the token transfer and of the token mint carries a random amount, which no + // table admits yet: block execution does not settle the amounts, so a reservation would + // never be paid out. New software must refuse the batch at every protocol version, 15 + // included, though the format decodes; version 0 stays active everywhere its batch is. #[test] - fn should_not_activate_a_version_1_token_transfer_or_mint_before_protocol_version_15() { - use crate::serialization::PlatformSerializable; + fn should_not_activate_a_version_1_token_transfer_or_mint_while_no_table_admits_it() { + use crate::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; use crate::state_transition::batch_transition::batched_transition::BatchedTransition; use crate::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; @@ -3692,26 +3719,67 @@ mod tests { let random_mint = batch(TokenTransition::Mint(TokenMintTransition::V1( TokenMintTransitionV1::from_v0(mint_v0, Some(10)), ))); - let version_14 = PlatformVersion::get(14).expect("platform version 14 exists"); for random in [random_transfer, random_mint] { - assert_eq!(random.active_version_range(), 15..=LATEST_VERSION); + assert_eq!(random.active_version_range(), NO_VERSION); let bytes = PlatformSerializable::serialize_to_bytes(&random).expect("serialize succeeds"); - assert!(matches!( - StateTransition::deserialize_from_bytes_untrusted_in_version(&bytes, version_14), - Err(ProtocolError::StateTransitionError( - StateTransitionIsNotActiveError { .. } - )) - )); - let recovered = StateTransition::deserialize_from_bytes_untrusted_in_version( - &bytes, - PlatformVersion::latest(), - ) - .expect("expected protocol version 15 to decode a version 1 token transition"); + for platform_version in PLATFORM_VERSIONS { + assert!( + matches!( + StateTransition::deserialize_from_bytes_untrusted_in_version( + &bytes, + platform_version + ), + Err(ProtocolError::StateTransitionError( + StateTransitionIsNotActiveError { .. } + )) + ), + "expected protocol version {} to refuse a random token amount", + platform_version.protocol_version + ); + } + // The format itself decodes: only the gate refuses it + let recovered = StateTransition::deserialize_from_bytes_untrusted(&bytes) + .expect("expected a version 1 token transition to decode"); assert_eq!(recovered, random); } } + // The release that settles random token amounts activates them by setting + // `random_token_amount` in its table: the active range starts at the first protocol version + // whose table has it, and is empty while none has it. + #[test] + fn should_activate_random_token_amounts_from_the_first_table_admitting_them() { + // No table admits it yet + assert_eq!( + random_token_amount_active_version_range(PLATFORM_VERSIONS), + NO_VERSION + ); + // No binary of protocol version 14 or earlier decodes the format, so none of their + // tables may ever admit it (14 shares its table with 15 for now) + assert!(PLATFORM_VERSIONS + .iter() + .filter(|platform_version| platform_version.protocol_version < 15) + .all(|platform_version| platform_version + .dpp + .state_transition_serialization_versions + .random_token_amount + .is_none())); + + let mut versions = PLATFORM_VERSIONS.to_vec(); + let version_15 = versions + .iter_mut() + .find(|platform_version| platform_version.protocol_version == 15) + .expect("platform version 15 exists"); + version_15 + .dpp + .state_transition_serialization_versions + .random_token_amount = Some(0); + let active = random_token_amount_active_version_range(&versions); + assert_eq!(active, 15..=LATEST_VERSION); + assert!(!active.contains(&14)); + } + fn sample_batch_st_empty() -> StateTransition { StateTransition::Batch(BatchTransition::V0(BatchTransitionV0 { owner_id: Identifier::from([1u8; 32]), 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 fa543fbd45e..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 @@ -9,7 +9,6 @@ mod freeze; mod mint; mod mint_many; mod non_transferable; -mod random_amount; mod shielded; mod transfer; mod unfreeze; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/random_amount/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/random_amount/mod.rs deleted file mode 100644 index e645207e794..00000000000 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/random_amount/mod.rs +++ /dev/null @@ -1,409 +0,0 @@ -//! End-to-end coverage for random token amounts (protocol version 15): a token transfer or -//! mint naming a random minimum amount reserves the most it moves in the token marketplace -//! escrow when the block executes it, and `Drive::settle_random_token_amounts` pays out the -//! amount drawn from the block's random beacon once the block's transitions are accepted. The -//! token conservation check balances throughout. A client cannot know the amount, so it -//! reads it from the proof of its own transition. - -use super::*; - -mod random_amount_tests { - use super::*; - use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::setup::TempPlatform; - use dpp::balances::credits::TokenAmount; - use dpp::prelude::DataContract; - use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransition; - use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; - use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; - use dpp::state_transition::batch_transition::{TokenMintTransition, TokenTransferTransition}; - use dpp::state_transition::proof_result::StateTransitionProofResult; - use dpp::state_transition::StateTransition; - use drive::drive::tokens::settle_random_token_amounts::{ - RandomTokenRequest, RandomTokenRequestKind, - }; - use drive::drive::Drive; - use std::collections::BTreeMap; - use std::sync::Arc; - - const BEACON: [u8; 96] = [7; 96]; - const BASE_SUPPLY: TokenAmount = 100_000; - const MINIMUM: TokenAmount = 100; - const MAXIMUM: TokenAmount = 1_000; - const NONCE: u64 = 2; - - fn random_options() -> Option { - Some(StateTransitionCreationOptions { - random_minimum_amount: Some(MINIMUM), - ..Default::default() - }) - } - - /// The token transition of the single-transition batch `transition`. - fn token_transition(transition: &StateTransition) -> &TokenTransition { - let StateTransition::Batch(BatchTransition::V1(batch)) = transition else { - panic!("expected a version 1 batch"); - }; - let [BatchedTransition::Token(token_transition)] = batch.transitions.as_slice() else { - panic!("expected a single token transition"); - }; - token_transition - } - - fn process( - platform: &TempPlatform, - transition: &StateTransition, - ) -> StateTransitionExecutionResult { - let platform_version = PlatformVersion::latest(); - let platform_state = platform.state.load(); - let serialized = transition - .serialize_to_bytes() - .expect("expected the transition to serialize"); - let transaction = platform.drive.grove.start_transaction(); - let processing_result = platform - .platform - .process_raw_state_transitions( - &[serialized], - &platform_state, - &BlockInfo::default(), - &transaction, - platform_version, - false, - None, - ) - .expect("expected to process the state transition"); - platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit the transaction"); - processing_result.into_execution_results().remove(0) - } - - fn balance( - platform: &TempPlatform, - token_id: Identifier, - identity_id: Identifier, - ) -> Option { - platform - .drive - .fetch_identity_token_balance( - token_id.to_buffer(), - identity_id.to_buffer(), - None, - PlatformVersion::latest(), - ) - .expect("expected to fetch a token balance") - } - - fn supply( - platform: &TempPlatform, - token_id: Identifier, - ) -> Option { - platform - .drive - .fetch_token_total_supply(token_id.to_buffer(), None, PlatformVersion::latest()) - .expect("expected to fetch the total supply") - } - - /// The identity balances, the shielded pools and the token marketplace escrow, which holds - /// the reservations, add up to the total supplies. - fn assert_tokens_balanced(platform: &TempPlatform) { - let total = platform - .drive - .calculate_total_tokens_balance(None, PlatformVersion::latest()) - .expect("expected to sum the token balances"); - assert!( - total.ok().expect("expected the sums to compare"), - "tokens are not balanced: {total}" - ); - } - - /// Proves the committed state `transition` wrote and verifies the proof as the client that - /// sent it does, holding the token's contract: it cannot know the drawn amount. - fn verify_proof( - platform: &TempPlatform, - transition: &StateTransition, - contract: &DataContract, - ) -> StateTransitionProofResult { - let platform_version = PlatformVersion::latest(); - let proof = platform - .drive - .prove_state_transition(transition, None, platform_version) - .expect("expected to prove the state transition") - .into_data() - .expect("expected proof bytes"); - let known_contracts: BTreeMap = - BTreeMap::from([(contract.id(), contract.clone())]); - let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( - transition, - &BlockInfo::default(), - &proof, - &|id| Ok(known_contracts.get(id).cloned().map(Arc::new)), - platform_version, - ) - .expect("expected the proof to verify"); - outcome.into_result() - } - - /// The amount of the history document a proof shows. - fn proved_amount(result: StateTransitionProofResult) -> TokenAmount { - let StateTransitionProofResult::VerifiedTokenActionWithDocument(document) = result else { - panic!("expected the proof to show the history document, got {result:?}"); - }; - document - .properties() - .get_integer("amount") - .expect("expected the history document to hold an amount") - } - - fn settle(platform: &TempPlatform, request: RandomTokenRequest) { - let settled = platform - .drive - .settle_random_token_amounts( - &BEACON, - &[request], - &BlockInfo::default(), - None, - PlatformVersion::latest(), - ) - .expect("expected the settlement to run"); - assert_eq!(settled, 1); - } - - #[tokio::test] - async fn should_reserve_a_random_transfer_then_pay_out_the_drawn_amount() { - 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(49855); - 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(), - None::, - None, - None, - None, - platform_version, - ); - - let transition = BatchTransition::new_token_transfer_transition( - token_id, - identity.id(), - contract.id(), - 0, - MAXIMUM, - recipient.id(), - None, - None, - None, - &key, - NONCE, - 0, - &signer, - platform_version, - random_options(), - ) - .await - .expect("expected to create the transfer"); - assert!(matches!( - token_transition(&transition), - TokenTransition::Transfer(TokenTransferTransition::V1(_)) - )); - - let result = process(&platform, &transition); - assert_matches!( - &result, - StateTransitionExecutionResult::SuccessfulExecution { .. }, - "{result:?}" - ); - // The block moves the most the transfer moves into a reservation, and writes the - // recipient's balance at zero, where it waits for the draw - assert_eq!( - balance(&platform, token_id, identity.id()), - Some(BASE_SUPPLY - MAXIMUM) - ); - assert_eq!(balance(&platform, token_id, recipient.id()), Some(0)); - assert_tokens_balanced(&platform); - // The history holds the range's minimum until the draw - assert_eq!( - proved_amount(verify_proof(&platform, &transition, &contract)), - MINIMUM - ); - - settle( - &platform, - RandomTokenRequest { - token_id, - owner_id: identity.id(), - identity_contract_nonce: NONCE, - minimum: MINIMUM, - maximum: MAXIMUM, - kind: RandomTokenRequestKind::Transfer { - sender_id: identity.id(), - recipient_id: recipient.id(), - }, - }, - ); - - let drawn = balance(&platform, token_id, recipient.id()).expect("the recipient is paid"); - assert!((MINIMUM..=MAXIMUM).contains(&drawn)); - assert_eq!( - balance(&platform, token_id, identity.id()), - Some(BASE_SUPPLY - drawn) - ); - assert_eq!(supply(&platform, token_id), Some(BASE_SUPPLY)); - assert_tokens_balanced(&platform); - // The client that sent the transfer reads the drawn amount from the proof of its own - // transition - assert_eq!( - proved_amount(verify_proof(&platform, &transition, &contract)), - drawn - ); - } - - #[tokio::test] - async fn should_reserve_a_random_mint_then_pay_out_the_drawn_amount() { - 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(49856); - 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(), - None::, - None, - None, - None, - platform_version, - ); - - let transition = BatchTransition::new_token_mint_transition( - token_id, - identity.id(), - contract.id(), - 0, - MAXIMUM, - Some(recipient.id()), - None, - None, - &key, - NONCE, - 0, - &signer, - platform_version, - random_options(), - ) - .await - .expect("expected to create the mint"); - assert!(matches!( - token_transition(&transition), - TokenTransition::Mint(TokenMintTransition::V1(_)) - )); - - let result = process(&platform, &transition); - assert_matches!( - &result, - StateTransitionExecutionResult::SuccessfulExecution { .. }, - "{result:?}" - ); - // The block adds the most the mint issues to the supply and holds it in a reservation, - // and writes the recipient's balance at zero, where it waits for the draw - assert_eq!(supply(&platform, token_id), Some(BASE_SUPPLY + MAXIMUM)); - assert_eq!(balance(&platform, token_id, recipient.id()), Some(0)); - assert_tokens_balanced(&platform); - - settle( - &platform, - RandomTokenRequest { - token_id, - owner_id: identity.id(), - identity_contract_nonce: NONCE, - minimum: MINIMUM, - maximum: MAXIMUM, - kind: RandomTokenRequestKind::Mint { - recipient_id: recipient.id(), - }, - }, - ); - - let drawn = balance(&platform, token_id, recipient.id()).expect("the recipient is paid"); - assert!((MINIMUM..=MAXIMUM).contains(&drawn)); - assert_eq!(supply(&platform, token_id), Some(BASE_SUPPLY + drawn)); - assert_eq!( - balance(&platform, token_id, identity.id()), - Some(BASE_SUPPLY) - ); - assert_tokens_balanced(&platform); - assert_eq!( - proved_amount(verify_proof(&platform, &transition, &contract)), - drawn - ); - } - - #[tokio::test] - async fn should_refuse_a_random_mint_without_a_recipient() { - 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(49857); - let (identity, signer, key) = - setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); - let (contract, token_id) = create_token_contract_with_owner_identity( - &mut platform, - identity.id(), - Some(|token_configuration: &mut TokenConfiguration| { - token_configuration - .distribution_rules_mut() - .set_new_tokens_destination_identity(Some(identity.id())); - }), - None, - None, - None, - platform_version, - ); - - let transition = BatchTransition::new_token_mint_transition( - token_id, - identity.id(), - contract.id(), - 0, - MAXIMUM, - None, - None, - None, - &key, - NONCE, - 0, - &signer, - platform_version, - random_options(), - ) - .await - .expect("expected to create the mint"); - - let error = match process(&platform, &transition) { - StateTransitionExecutionResult::UnpaidConsensusError(error) => error, - StateTransitionExecutionResult::PaidConsensusError { error, .. } => error, - other => panic!("expected the mint to be refused, got {other:?}"), - }; - assert_matches!( - error, - ConsensusError::BasicError(BasicError::RandomTokenMintNotAllowedError(_)) - ); - assert_eq!(supply(&platform, token_id), Some(BASE_SUPPLY)); - } -} diff --git a/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs index fe4b6626927..d0930cd431a 100644 --- a/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs @@ -52,12 +52,10 @@ impl Drive { Ok(drawn) } - /// Generation 0 of [`Self::prepay_random_document_index_entries`]: the indexes of - /// `document_type` holding a `sys.random` property, alone in a copy of the type - /// (`with_only_indexes`), and the document's entries in them gathered as the insert gathers - /// a document's index entries in a dry run, with the layers above the type estimated as for - /// any document insert and the storage flags the create writes them with, then priced as - /// the dry run's operations are. + /// Generation 0 of [`Self::prepay_random_document_index_entries`]: the entries of the + /// document in the indexes of `document_type` holding a `sys.random` property + /// ([`Self::prepay_drawn_index_entries_v0`]), carrying the storage flags the create writes + /// them with. #[allow(clippy::too_many_arguments)] #[inline(always)] pub(super) fn prepay_random_document_index_entries_v0( @@ -70,18 +68,58 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { - let drawn_into = |index: &Index| indexes_a_random_property(document_type, index); - if !document_type.indexes().values().any(drawn_into) { + // The flags the create writes the document's elements with + let storage_flags = + StorageFlags::new_single_epoch(block_info.epoch.index, Some(owner_id.to_buffer())); + self.prepay_drawn_index_entries_v0( + document, + owner_id, + Some(storage_flags), + contract, + document_type, + |index| indexes_a_random_property(document_type, index), + block_info, + transaction, + platform_version, + ) + } + + /// What Drive's fee estimation gives, without reading state, for inserting the entries + /// `document`, of `document_type` in `contract`, written by `owner_id` with + /// `storage_flags`, takes in the indexes of its type that `drawn_into` selects: the indexes + /// holding a value a block's random beacon draws once the block is accepted, whose rewrite + /// moves the document's entries and may create the branches they move into, unbilled. The + /// selected indexes stand alone in a copy of the type (`with_only_indexes`), the entries + /// are gathered as the insert gathers a document's index entries in a dry run, with the + /// layers above the type estimated as for any document insert (the average layer + /// information, as if the branches had to be created), and priced as the dry run's + /// operations are. Zero when `drawn_into` selects no index of the type. + /// + /// Shared by the prepayments of generation 0 of both draws: a `sys.random` document + /// property's ([`Self::prepay_random_document_index_entries`]) and the amount a random + /// token transfer or mint writes into its history document + /// (`Drive::prepay_random_token_history_entries`). + #[allow(clippy::too_many_arguments)] + pub(crate) fn prepay_drawn_index_entries_v0( + &self, + document: &Document, + owner_id: Identifier, + storage_flags: Option, + contract: &DataContract, + document_type: DocumentTypeRef, + drawn_into: impl Fn(&Index) -> bool, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + if !document_type.indexes().values().any(&drawn_into) { return Ok(FeeResult::default()); } - let indexes_drawn_into = document_type.with_only_indexes(drawn_into, platform_version)?; - let owner = owner_id.to_buffer(); - // The flags the create writes the document's elements with - let storage_flags = StorageFlags::new_single_epoch(block_info.epoch.index, Some(owner)); + let indexes_drawn_into = document_type.with_only_indexes(&drawn_into, platform_version)?; let document_and_contract_info = DocumentAndContractInfo { owned_document_info: OwnedDocumentInfo { - document_info: DocumentRefInfo((document, Some(Cow::Owned(storage_flags)))), - owner_id: Some(owner), + document_info: DocumentRefInfo((document, storage_flags.map(Cow::Owned))), + owner_id: Some(owner_id.to_buffer()), }, contract, document_type: indexes_drawn_into.as_ref(), diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs index 61fc4646737..c328db99a51 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs @@ -5,7 +5,9 @@ use crate::error::drive::DriveError; use crate::error::Error; use dpp::balances::credits::TokenAmount; use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; use dpp::prelude::{Identifier, IdentityNonce}; +use dpp::tokens::token_event::TokenEvent; use dpp::util::hash::hash_double; use dpp::version::PlatformVersion; use grovedb::TransactionArg; @@ -104,7 +106,9 @@ impl Drive { /// When the token keeps the history of the kind, the transition's history document, which /// holds the range's minimum, is rewritten in place to hold `v`. Settling only pays out or /// releases what the block reserved, so it cannot fail for a logical reason. Nobody pays: - /// the transition paid for its execution when the block ran it, and the settlement is free. + /// the transition paid for its execution when the block ran it, and prepaid the history + /// index entries the rewrite may create ([`Self::prepay_random_token_history_entries`]), so + /// the settlement is free. /// /// # Parameters /// - `beacon`: the block's random beacon. @@ -149,4 +153,68 @@ impl Drive { })), } } + + /// What a random token transfer or mint (protocol version 15) whose token keeps the + /// history of its kind pays in advance for the settlement's rewrite of its history document + /// ([`Self::settle_random_token_amounts`]). The transition writes the document holding the + /// range's minimum, and the settlement moves its entry in each index of the document's type + /// holding `amount` (`byAmount`, under the token) to the drawn amount. The branch it moves + /// into may not exist yet: the settlement creates it, and is not billed. So the transition + /// pays for those entries once more, at what Drive's fee estimation gives for inserting the + /// document's entries into those indexes without reading state: the average layer + /// information it prices a dry run with, as if the branches had to be created (the average + /// case, whatever the draw finds). Selected by the settlement's method version, whose cost + /// model it is part of. + /// + /// # Parameters + /// - `token_id`: the token. + /// - `owner_id`: the identity that sent the transition, whose history document it is. + /// - `identity_contract_nonce`: the transition's identity contract nonce, which keys the + /// history document. + /// - `event`: the history event the transition records, a transfer or a mint of the range's + /// minimum. + /// - `block_info`: the block, whose epoch prices the entries. + /// - `transaction`: the block's transaction. + /// - `platform_version`: selects the method version; `None` before protocol version 15, + /// which is the first to decode a random token amount. + /// + /// # Returns + /// The fee of those entries. + #[allow(clippy::too_many_arguments)] + pub fn prepay_random_token_history_entries( + &self, + token_id: Identifier, + owner_id: Identifier, + identity_contract_nonce: IdentityNonce, + event: TokenEvent, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .token + .settle_random_token_amounts + { + Some(0) => self.prepay_random_token_history_entries_v0( + token_id, + owner_id, + identity_contract_nonce, + event, + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "prepay_random_token_history_entries".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "prepay_random_token_history_entries".to_string(), + known_versions: vec![0], + })), + } + } } diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs index 397ccfca241..74a9949ce2a 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs @@ -15,10 +15,12 @@ use dpp::balances::credits::TokenAmount; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::{integer_value_for_type, RandomRange}; +use dpp::data_contract::document_type::{integer_value_for_type, Index, RandomRange}; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::{Document, DocumentV0Getters}; -use dpp::prelude::DataContract; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{DataContract, Identifier, IdentityNonce}; +use dpp::tokens::token_event::TokenEvent; use dpp::version::PlatformVersion; use grovedb::{Element, TransactionArg}; use std::borrow::Cow; @@ -165,6 +167,53 @@ impl Drive { ) } + /// Generation 0 of [`Self::prepay_random_token_history_entries`]: the entries of the + /// history document `event` builds, as `add_token_transaction_history_operations` writes + /// it (without storage flags), in the indexes of its type holding the amount + /// (`Drive::prepay_drawn_index_entries_v0`). + #[allow(clippy::too_many_arguments)] + #[inline(always)] + pub(super) fn prepay_random_token_history_entries_v0( + &self, + token_id: Identifier, + owner_id: Identifier, + identity_contract_nonce: IdentityNonce, + event: TokenEvent, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let token_history_contract = self + .cache + .system_data_contracts + .load_token_history(platform_version)?; + let contract: &DataContract = &token_history_contract; + let document_type = event.associated_document_type(contract)?; + let document = event.build_historical_document_owned( + token_id, + owner_id, + identity_contract_nonce, + block_info, + platform_version, + )?; + self.prepay_drawn_index_entries_v0( + &document, + owner_id, + None, + contract, + document_type, + |index: &Index| { + index + .properties + .iter() + .any(|property| property.name == HISTORY_AMOUNT_PROPERTY) + }, + block_info, + transaction, + platform_version, + ) + } + /// The operations rewriting the history document of `request`'s transition to hold /// `amount`, none when the token keeps no history of the kind: the transition then wrote no /// history document. The document is found the way it was written @@ -281,9 +330,7 @@ mod tests { use crate::util::batch::DriveOperation; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::platform_value::Value; - use dpp::prelude::Identifier; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; - use dpp::tokens::token_event::TokenEvent; const BEACON: [u8; 96] = [7; 96]; const TOKEN: [u8; 32] = [5; 32]; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs index d90923c6219..dc022c143b3 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs @@ -6,7 +6,6 @@ use dpp::consensus::basic::token::{ChoosingTokenMintRecipientNotAllowedError, De use dpp::identifier::Identifier; use dpp::state_transition::batch_transition::token_mint_transition::v0::TokenMintTransitionV0; use dpp::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV1; -use dpp::balances::credits::TokenAmount; use dpp::ProtocolError; use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; @@ -17,6 +16,8 @@ use crate::state_transition_action::batch::batched_transition::token_transition: use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dpp::fee::fee_result::FeeResult; +use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Getters; +use dpp::tokens::token_event::TokenEvent; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use platform_version::version::PlatformVersion; use crate::drive::Drive; @@ -383,7 +384,9 @@ impl TokenMintTransitionActionV0 { /// Converts a `TokenMintTransitionV1` into a `TokenMintTransitionActionV0`: the version 0 /// conversion of its version 0 fields, the mint action it yields carrying the random - /// minimum amount. + /// minimum amount. A random mint of a token keeping minting history also pays in advance + /// for the history index entries the settlement of its amount may create + /// (`Drive::prepay_random_token_history_entries`). /// /// # Arguments /// @@ -393,7 +396,8 @@ impl TokenMintTransitionActionV0 { /// # Returns /// /// * `Result<(ConsensusValidationResult, FeeResult), Error>` - The - /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made. + /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made, + /// and of the prepaid history index entries. #[allow(clippy::too_many_arguments)] pub fn try_from_token_mint_transition_v1_with_contract_lookup( drive: &Drive, @@ -413,24 +417,63 @@ impl TokenMintTransitionActionV0 { Error, > { let (v0, random_minimum_amount) = value.into_v0_and_random_minimum_amount(); - let (mut result, fee_result) = Self::try_from_token_mint_transition_with_contract_lookup( - drive, - owner_id, - v0, - approximate_without_state_for_costs, - transaction, - block_info, - user_fee_increase, - get_data_contract, - platform_version, - )?; - set_random_minimum_amount(&mut result, random_minimum_amount); + let (mut result, mut fee_result) = + Self::try_from_token_mint_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + )?; + // A refused mint holds a nonce bump instead, which has no random amount and writes no + // history + if let Some(BatchedTransitionAction::TokenAction(TokenTransitionAction::MintAction( + TokenMintTransitionAction::V0(mint), + ))) = result.data.as_mut() + { + mint.random_minimum_amount = random_minimum_amount; + + // The execution writes the mint's history document holding the range's minimum, + // and the settlement moves its `byAmount` index entry to the drawn amount, which may + // create a branch the settlement does not bill. So the mint prepays that entry at + // the average case: what Drive's fee estimation gives for inserting the document's + // entries into the indexes holding the amount, without reading state. The execution + // writes the history only for a mint it performs, which a random mint always is (it + // is no group action). + if let Some(minimum) = random_minimum_amount { + if mint.base.perform_action() + && mint + .base + .token_configuration()? + .keeps_history() + .keeps_minting_history() + { + let history_event = TokenEvent::Mint( + minimum, + mint.identity_balance_holder_id, + mint.public_note.clone(), + ); + fee_result.checked_add_assign(drive.prepay_random_token_history_entries( + mint.base.token_id(), + owner_id, + mint.base.identity_contract_nonce(), + history_event, + block_info, + transaction, + platform_version, + )?)?; + } + } + } Ok((result, fee_result)) } - /// Converts a borrowed `TokenMintTransitionV1` into a `TokenMintTransitionActionV0`: the - /// version 0 conversion of its version 0 fields, the mint action it yields carrying the - /// random minimum amount. + /// Converts a borrowed `TokenMintTransitionV1` into a `TokenMintTransitionActionV0`: + /// [`Self::try_from_token_mint_transition_v1_with_contract_lookup`] of a copy. /// /// # Arguments /// @@ -440,7 +483,8 @@ impl TokenMintTransitionActionV0 { /// # Returns /// /// * `Result<(ConsensusValidationResult, FeeResult), Error>` - The - /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made. + /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made, + /// and of the prepaid history index entries. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_mint_transition_v1_with_contract_lookup( drive: &Drive, @@ -459,34 +503,152 @@ impl TokenMintTransitionActionV0 { ), Error, > { - let (v0, random_minimum_amount) = value.clone().into_v0_and_random_minimum_amount(); - let (mut result, fee_result) = - Self::try_from_borrowed_token_mint_transition_with_contract_lookup( - drive, - owner_id, - &v0, - approximate_without_state_for_costs, - transaction, - block_info, - user_fee_increase, - get_data_contract, - platform_version, - )?; - set_random_minimum_amount(&mut result, random_minimum_amount); - Ok((result, fee_result)) + // One copy of the fields, moved into the owned conversion, which builds the same action + // as the borrowed one does without copying the note a second time + Self::try_from_token_mint_transition_v1_with_contract_lookup( + drive, + owner_id, + value.clone(), + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) } } -/// Sets `random_minimum_amount` on the mint action `result` holds; a refused mint holds a nonce -/// bump instead, which has none. -fn set_random_minimum_amount( - result: &mut ConsensusValidationResult, - random_minimum_amount: Option, -) { - if let Some(BatchedTransitionAction::TokenAction(TokenTransitionAction::MintAction( - TokenMintTransitionAction::V0(mint), - ))) = result.data.as_mut() - { - mint.random_minimum_amount = random_minimum_amount; +#[cfg(test)] +mod random_amount_prepay_tests { + use super::*; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_keeps_history_rules::v0::TokenKeepsHistoryRulesV0; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::v1::DataContractV1; + use dpp::data_contract::DataContract; + use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; + use grovedb_costs::OperationCost; + use std::collections::BTreeMap; + + const CONTRACT: [u8; 32] = [3; 32]; + const MINTER: [u8; 32] = [1; 32]; + const RECIPIENT: [u8; 32] = [2; 32]; + const MINIMUM: u64 = 100; + const MAXIMUM: u64 = 1_000; + + /// A contract whose token, at position 0, keeps every kind of history when `keeps_history` + /// and none otherwise, and lets a mint choose its recipient. + fn token_contract(keeps_history: bool) -> Arc { + let mut token = TokenConfigurationV0::default_most_restrictive(); + token.keeps_history = + TokenKeepsHistoryRulesV0::default_for_keeping_all_history(keeps_history).into(); + assert!( + token + .distribution_rules + .minting_allow_choosing_destination(), + "expected the token to let a mint choose its recipient" + ); + let contract = DataContract::V1(DataContractV1 { + id: CONTRACT.into(), + version: 0, + owner_id: MINTER.into(), + document_types: Default::default(), + config: DataContractConfigV0::default().into(), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([(0, TokenConfiguration::V0(token))]), + keywords: Vec::new(), + description: None, + }); + Arc::new(DataContractFetchInfo { + contract, + storage_flags: None, + cost: OperationCost::default(), + fee: None, + }) + } + + /// What building the action of a mint of up to `MAXIMUM` tokens bills, a random one from + /// `MINIMUM` when `random`. + fn mint_fee(keeps_history: bool, random: bool) -> FeeResult { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let fetch_info = token_contract(keeps_history); + let token_id = fetch_info + .contract + .token_id(0) + .expect("expected a token at position 0"); + let random_minimum_amount = random.then_some(MINIMUM); + let transition = TokenMintTransitionV1 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 1, + token_contract_position: 0, + data_contract_id: CONTRACT.into(), + token_id, + using_group_info: None, + }), + issued_to_identity_id: Some(RECIPIENT.into()), + amount: MAXIMUM, + public_note: Some("for the raffle".to_string()), + random_minimum_amount, + }; + let (result, fee_result) = + TokenMintTransitionActionV0::try_from_borrowed_token_mint_transition_v1_with_contract_lookup( + &drive, + MINTER.into(), + &transition, + false, + None, + &BlockInfo::default(), + 0, + |_| Ok(fetch_info.clone()), + platform_version, + ) + .expect("expected the action to build"); + assert!( + result.is_valid(), + "expected a valid mint: {:?}", + result.errors + ); + let Some(BatchedTransitionAction::TokenAction(TokenTransitionAction::MintAction( + TokenMintTransitionAction::V0(mint), + ))) = result.data + else { + panic!("expected a mint action"); + }; + assert_eq!(mint.random_minimum_amount, random_minimum_amount); + assert_eq!(mint.mint_amount, MAXIMUM); + assert_eq!(mint.identity_balance_holder_id, Identifier::from(RECIPIENT)); + assert_eq!(mint.public_note.as_deref(), Some("for the raffle")); + fee_result + } + + #[test] + fn should_prepay_the_history_entry_of_a_random_mint() { + let random = mint_fee(true, true); + let fixed = mint_fee(true, false); + assert!( + random.storage_fee > fixed.storage_fee, + "expected the random mint to prepay storage: {random:?} vs {fixed:?}" + ); + assert!( + random.processing_fee > fixed.processing_fee, + "expected the random mint to prepay processing: {random:?} vs {fixed:?}" + ); + } + + #[test] + fn should_prepay_nothing_for_a_random_mint_of_a_token_keeping_no_minting_history() { + assert_eq!(mint_fee(false, true), mint_fee(false, false)); } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs index 74e4e33fd62..19e11ff7d9d 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs @@ -1,3 +1,5 @@ +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Getters; use dpp::identifier::Identifier; use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; use dpp::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV1; @@ -6,11 +8,12 @@ use grovedb::TransactionArg; use std::sync::Arc; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; +use dpp::tokens::token_event::TokenEvent; use platform_version::version::PlatformVersion; use crate::drive::contract::DataContractFetchInfo; use crate::drive::Drive; use crate::error::Error; -use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_transfer_transition_action::TokenTransferTransitionActionV0; impl TokenTransferTransitionActionV0 { @@ -184,7 +187,10 @@ impl TokenTransferTransitionActionV0 { } /// Converts a `TokenTransferTransitionV1` into a `TokenTransferTransitionActionV0`: the - /// version 0 conversion of its version 0 fields, carrying its random minimum amount. + /// version 0 conversion of its version 0 fields, carrying its random minimum amount. A + /// random transfer of a token keeping transfer history also pays in advance for the history + /// index entries the settlement of its amount may create + /// (`Drive::prepay_random_token_history_entries`). /// /// # Arguments /// @@ -194,7 +200,7 @@ impl TokenTransferTransitionActionV0 { /// # Returns /// /// * `Result<(TokenTransferTransitionActionV0, FeeResult), Error>` - The action and the fees - /// of the lookups it made. + /// of the lookups it made, and of the prepaid history index entries. #[allow(clippy::too_many_arguments)] pub fn try_from_token_transfer_transition_v1_with_contract_lookup( drive: &Drive, @@ -207,7 +213,7 @@ impl TokenTransferTransitionActionV0 { platform_version: &PlatformVersion, ) -> Result<(Self, FeeResult), Error> { let (v0, random_minimum_amount) = value.into_v0_and_random_minimum_amount(); - let (mut action, fee_result) = + let (mut action, mut fee_result) = Self::try_from_token_transfer_transition_with_contract_lookup( drive, owner_id, @@ -219,11 +225,42 @@ impl TokenTransferTransitionActionV0 { platform_version, )?; action.random_minimum_amount = random_minimum_amount; + + // The execution writes the transfer's history document holding the range's minimum, + // and the settlement moves its `byAmount` index entry to the drawn amount, which may + // create a branch the settlement does not bill. So the transfer prepays that entry at + // the average case: what Drive's fee estimation gives for inserting the document's + // entries into the indexes holding the amount, without reading state. + if let Some(minimum) = random_minimum_amount { + if action + .base + .token_configuration()? + .keeps_history() + .keeps_transfer_history() + { + let history_event = TokenEvent::Transfer( + action.recipient_id, + action.public_note.clone(), + action.shared_encrypted_note.clone(), + action.private_encrypted_note.clone(), + minimum, + ); + fee_result.checked_add_assign(drive.prepay_random_token_history_entries( + action.base.token_id(), + owner_id, + action.base.identity_contract_nonce(), + history_event, + block_info, + transaction, + platform_version, + )?)?; + } + } Ok((action, fee_result)) } /// Converts a borrowed `TokenTransferTransitionV1` into a `TokenTransferTransitionActionV0`: - /// the version 0 conversion of its version 0 fields, carrying its random minimum amount. + /// [`Self::try_from_token_transfer_transition_v1_with_contract_lookup`] of a copy. /// /// # Arguments /// @@ -234,7 +271,7 @@ impl TokenTransferTransitionActionV0 { /// # Returns /// /// * `Result<(TokenTransferTransitionActionV0, FeeResult), Error>` - The action and the fees - /// of the lookups it made. + /// of the lookups it made, and of the prepaid history index entries. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_transfer_transition_v1_with_contract_lookup( drive: &Drive, @@ -246,20 +283,18 @@ impl TokenTransferTransitionActionV0 { get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, platform_version: &PlatformVersion, ) -> Result<(Self, FeeResult), Error> { - let (v0, random_minimum_amount) = value.clone().into_v0_and_random_minimum_amount(); - let (mut action, fee_result) = - Self::try_from_borrowed_token_transfer_transition_with_contract_lookup( - drive, - owner_id, - &v0, - approximate_without_state_for_costs, - transaction, - block_info, - get_data_contract, - platform_version, - )?; - action.random_minimum_amount = random_minimum_amount; - Ok((action, fee_result)) + // One copy of the fields, moved into the owned conversion, which builds the same action + // as the borrowed one does without copying the notes a second time + Self::try_from_token_transfer_transition_v1_with_contract_lookup( + drive, + owner_id, + value.clone(), + approximate_without_state_for_costs, + transaction, + block_info, + get_data_contract, + platform_version, + ) } } @@ -588,3 +623,121 @@ mod tests { assert_eq!(ref_id, Identifier::new([21u8; 32])); } } + +#[cfg(test)] +mod random_amount_prepay_tests { + use super::*; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::data_contract::accessors::v1::DataContractV1Getters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_keeps_history_rules::v0::TokenKeepsHistoryRulesV0; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::v1::DataContractV1; + use dpp::data_contract::DataContract; + use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; + use grovedb_costs::OperationCost; + use std::collections::BTreeMap; + + const CONTRACT: [u8; 32] = [3; 32]; + const SENDER: [u8; 32] = [1; 32]; + const RECIPIENT: [u8; 32] = [2; 32]; + const MINIMUM: u64 = 100; + const MAXIMUM: u64 = 1_000; + + /// A contract whose token, at position 0, keeps every kind of history when `keeps_history` + /// and none otherwise. + fn token_contract(keeps_history: bool) -> Arc { + let mut token = TokenConfigurationV0::default_most_restrictive(); + token.keeps_history = + TokenKeepsHistoryRulesV0::default_for_keeping_all_history(keeps_history).into(); + let contract = DataContract::V1(DataContractV1 { + id: CONTRACT.into(), + version: 0, + owner_id: SENDER.into(), + document_types: Default::default(), + config: DataContractConfigV0::default().into(), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([(0, TokenConfiguration::V0(token))]), + keywords: Vec::new(), + description: None, + }); + Arc::new(DataContractFetchInfo { + contract, + storage_flags: None, + cost: OperationCost::default(), + fee: None, + }) + } + + /// What building the action of a transfer of up to `MAXIMUM` tokens bills, a random one + /// from `MINIMUM` when `random`. + fn transfer_fee(keeps_history: bool, random: bool) -> FeeResult { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let fetch_info = token_contract(keeps_history); + let token_id = fetch_info + .contract + .token_id(0) + .expect("expected a token at position 0"); + let random_minimum_amount = random.then_some(MINIMUM); + let transition = TokenTransferTransitionV1 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 1, + token_contract_position: 0, + data_contract_id: CONTRACT.into(), + token_id, + using_group_info: None, + }), + amount: MAXIMUM, + recipient_id: RECIPIENT.into(), + public_note: Some("for the raffle".to_string()), + shared_encrypted_note: None, + private_encrypted_note: None, + random_minimum_amount, + }; + let (action, fee_result) = + TokenTransferTransitionActionV0::try_from_borrowed_token_transfer_transition_v1_with_contract_lookup( + &drive, + SENDER.into(), + &transition, + false, + None, + &BlockInfo::default(), + |_| Ok(fetch_info.clone()), + platform_version, + ) + .expect("expected the action to build"); + assert_eq!(action.random_minimum_amount, random_minimum_amount); + assert_eq!(action.amount, MAXIMUM); + assert_eq!(action.public_note.as_deref(), Some("for the raffle")); + fee_result + } + + #[test] + fn should_prepay_the_history_entry_of_a_random_transfer() { + let random = transfer_fee(true, true); + let fixed = transfer_fee(true, false); + assert!( + random.storage_fee > fixed.storage_fee, + "expected the random transfer to prepay storage: {random:?} vs {fixed:?}" + ); + assert!( + random.processing_fee > fixed.processing_fee, + "expected the random transfer to prepay processing: {random:?} vs {fixed:?}" + ); + } + + #[test] + fn should_prepay_nothing_for_a_random_transfer_of_a_token_keeping_no_transfer_history() { + assert_eq!(transfer_fee(false, true), transfer_fee(false, false)); + } +} diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index f7c0e440152..3c4bf1ece4d 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -3657,6 +3657,9 @@ fn verify_contract_document_change_execution( #[cfg(test)] mod group_burn_tests; +#[cfg(test)] +mod random_amount_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs new file mode 100644 index 00000000000..0f824eb1f34 --- /dev/null +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs @@ -0,0 +1,421 @@ +//! The proof of a random token transfer or mint (protocol version 15): its sender cannot know +//! the amount the block's random beacon drew, so verification takes it from the proved history +//! document once it lies in the range the transition asked for, and compares every other field +//! as for any transition. Each test verifies one genuine proof, built from a drive that executed +//! and settled the transition, against expectations that differ from the transition sent. + +use super::*; +use crate::drive::tokens::settle_random_token_amounts::{ + RandomTokenRequest, RandomTokenRequestKind, +}; +use crate::util::batch::drive_op_batch::TokenOperationType; +use crate::util::batch::DriveOperation; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; +use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; +use dpp::data_contract::associated_token::token_keeps_history_rules::v0::TokenKeepsHistoryRulesV0; +use dpp::data_contract::config::v0::DataContractConfigV0; +use dpp::data_contract::v1::DataContractV1; +use dpp::data_contract::DataContract; +use dpp::identity::accessors::IdentitySettersV0; +use dpp::identity::Identity; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; +use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; +use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use dpp::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV1; +use dpp::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV1; +use dpp::state_transition::batch_transition::{ + BatchTransition, BatchTransitionV1, TokenMintTransition, TokenTransferTransition, +}; +use dpp::tokens::token_event::TokenEvent; +use std::sync::Arc; + +const CONTRACT: [u8; 32] = [3; 32]; +const OWNER: [u8; 32] = [1; 32]; +const RECIPIENT: [u8; 32] = [2; 32]; +const OTHER_RECIPIENT: [u8; 32] = [5; 32]; +const CONTRACT_OWNER: [u8; 32] = [4; 32]; +const NONCE: u64 = 1; +const CREDITS: u64 = 1_000_000; +const BASE_SUPPLY: TokenAmount = 10_000; +const MINIMUM: TokenAmount = 100; +const MAXIMUM: TokenAmount = 1_000; +const BEACON: [u8; 96] = [7; 96]; +const NOTE: &str = "for the raffle"; + +/// Which random transition the fixture executed. +#[derive(Clone, Copy)] +enum Kind { + Transfer, + Mint, +} + +struct Fixture { + drive: Drive, + contract: DataContract, + token_id: Identifier, + kind: Kind, +} + +impl Fixture { + /// A drive whose token keeps every kind of history, after a block executed a random `kind` + /// from `MINIMUM` to `MAXIMUM`, sent by `OWNER` to `RECIPIENT` with `NOTE`, and settled it + /// from `BEACON`: the history document holds the drawn amount. + fn new(kind: Kind) -> Self { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let mut token = TokenConfigurationV0::default_most_restrictive().with_base_supply(0); + token.keeps_history = + TokenKeepsHistoryRulesV0::default_for_keeping_all_history(true).into(); + let contract = DataContract::V1(DataContractV1 { + id: CONTRACT.into(), + version: 0, + owner_id: CONTRACT_OWNER.into(), + document_types: Default::default(), + config: DataContractConfigV0::default().into(), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([(0, TokenConfiguration::V0(token))]), + keywords: Vec::new(), + description: None, + }); + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the token contract"); + let history_contract = + load_system_data_contract(SystemDataContract::TokenHistory, platform_version) + .expect("expected the token history contract"); + drive + .insert_contract( + &history_contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the token history contract"); + let token_id = contract + .token_id(0) + .expect("expected a token at position 0"); + + // The sender's credit balance rides along in the proof + let mut identity = Identity::random_identity(1, Some(14), platform_version) + .expect("expected to create an identity"); + identity.set_id(OWNER.into()); + identity.set_balance(CREDITS); + drive + .add_new_identity( + identity, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the sender"); + drive + .token_mint( + token_id.to_buffer(), + OWNER, + BASE_SUPPLY, + true, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to mint the base supply to the sender"); + + // What the block's execution of the transition writes: the reservation, and the + // history document holding the range's minimum + let (reservation, event, request_kind) = match kind { + Kind::Transfer => ( + TokenOperationType::TokenReserveRandomTransfer { + token_id, + sender_id: OWNER.into(), + recipient_id: RECIPIENT.into(), + identity_contract_nonce: NONCE, + maximum: MAXIMUM, + }, + TokenEvent::Transfer( + RECIPIENT.into(), + Some(NOTE.to_string()), + None, + None, + MINIMUM, + ), + RandomTokenRequestKind::Transfer { + sender_id: OWNER.into(), + recipient_id: RECIPIENT.into(), + }, + ), + Kind::Mint => ( + TokenOperationType::TokenReserveRandomMint { + token_id, + owner_id: OWNER.into(), + recipient_id: RECIPIENT.into(), + identity_contract_nonce: NONCE, + maximum: MAXIMUM, + allow_first_mint: false, + }, + TokenEvent::Mint(MINIMUM, RECIPIENT.into(), Some(NOTE.to_string())), + RandomTokenRequestKind::Mint { + recipient_id: RECIPIENT.into(), + }, + ), + }; + drive + .apply_drive_operations( + vec![ + DriveOperation::TokenOperation(reservation), + DriveOperation::TokenOperation(TokenOperationType::TokenHistory { + token_id, + owner_id: OWNER.into(), + nonce: NONCE, + event, + }), + ], + true, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("expected the block to execute the random transition"); + + // The settlement draws the amount and rewrites the history document to hold it + drive + .settle_random_token_amounts( + &BEACON, + &[RandomTokenRequest { + token_id, + owner_id: OWNER.into(), + identity_contract_nonce: NONCE, + minimum: MINIMUM, + maximum: MAXIMUM, + kind: request_kind, + }], + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected the settlement to run"); + + Self { + drive, + contract, + token_id, + kind, + } + } + + /// The random transition of the fixture's kind, as a client expecting it sends it: from + /// `minimum` to `maximum`, to `recipient`, with `note`. + fn transition( + &self, + minimum: TokenAmount, + maximum: TokenAmount, + recipient: [u8; 32], + note: &str, + ) -> StateTransition { + let base = TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: NONCE, + token_contract_position: 0, + data_contract_id: CONTRACT.into(), + token_id: self.token_id, + using_group_info: None, + }); + let token_transition = match self.kind { + Kind::Transfer => { + TokenTransition::Transfer(TokenTransferTransition::V1(TokenTransferTransitionV1 { + base, + amount: maximum, + recipient_id: recipient.into(), + public_note: Some(note.to_string()), + shared_encrypted_note: None, + private_encrypted_note: None, + random_minimum_amount: Some(minimum), + })) + } + Kind::Mint => TokenTransition::Mint(TokenMintTransition::V1(TokenMintTransitionV1 { + base, + issued_to_identity_id: Some(recipient.into()), + amount: maximum, + public_note: Some(note.to_string()), + random_minimum_amount: Some(minimum), + })), + }; + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: OWNER.into(), + transitions: vec![BatchedTransition::Token(token_transition)], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + })) + } + + /// The transition the fixture executed. + fn genuine_transition(&self) -> StateTransition { + self.transition(MINIMUM, MAXIMUM, RECIPIENT, NOTE) + } + + /// The proof of the genuine transition's execution, from the drive's state. + fn proof(&self) -> Vec { + self.drive + .prove_state_transition(&self.genuine_transition(), None, PlatformVersion::latest()) + .expect("expected to prove the transition") + .into_data() + .expect("expected proof bytes") + } + + /// Verifies `proof` as the execution of `transition`, with the Merkle verifier a client + /// runs. + fn verify( + &self, + proof: &[u8], + transition: &StateTransition, + ) -> Result<(RootHash, StateTransitionProofOutcome), Error> { + Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + proof, + &|_| Ok(Some(Arc::new(self.contract.clone()))), + PlatformVersion::latest(), + ) + } + + /// The amount the proved history document holds, read through the verification of the + /// genuine transition. + fn proved_amount(&self, proof: &[u8]) -> TokenAmount { + let (root_hash, outcome) = self + .verify(proof, &self.genuine_transition()) + .expect("expected the genuine transition to verify"); + assert_eq!( + root_hash, + self.drive + .grove + .root_hash(None, &PlatformVersion::latest().drive.grove_version) + .unwrap() + .expect("expected the stored root hash") + ); + match outcome.into_result() { + VerifiedTokenActionWithDocument(document) => document + .properties() + .get_integer("amount") + .expect("expected the history document to hold an amount"), + other => panic!("expected the history document, got {other:?}"), + } + } +} + +fn assert_unexpected_result( + result: Result<(RootHash, StateTransitionProofOutcome), Error>, + expectation: &str, +) { + match result { + Err(Error::Proof(ProofError::UnexpectedResultProof(_))) => {} + Err(error) => { + panic!("expected {expectation} to fail as an unexpected result, got {error:?}") + } + Ok(_) => panic!("expected {expectation} to fail, but it verified"), + } +} + +/// The proved amount verifies against a range holding it, its bounds included, and fails +/// against a range above or below it. +fn assert_amount_checked_against_the_range(kind: Kind) { + let fixture = Fixture::new(kind); + let proof = fixture.proof(); + let drawn = fixture.proved_amount(&proof); + assert!( + (MINIMUM..=MAXIMUM).contains(&drawn), + "expected the settled amount {drawn} to lie in the range sent" + ); + + for (minimum, maximum) in [(drawn, drawn + 10), (drawn - 10, drawn), (drawn, drawn)] { + fixture + .verify( + &proof, + &fixture.transition(minimum, maximum, RECIPIENT, NOTE), + ) + .unwrap_or_else(|error| { + panic!( + "expected the range {minimum} to {maximum} holding the proved amount \ + {drawn} to verify, got {error:?}" + ) + }); + } + + assert_unexpected_result( + fixture.verify( + &proof, + &fixture.transition(drawn + 1, drawn + 10, RECIPIENT, NOTE), + ), + "the proved amount below the minimum", + ); + assert_unexpected_result( + fixture.verify( + &proof, + &fixture.transition(drawn - 10, drawn - 1, RECIPIENT, NOTE), + ), + "the proved amount above the maximum", + ); +} + +/// Only the amount is exempt from the comparison with the expected history document: the +/// genuine range with another recipient or another note fails. +fn assert_other_fields_compared(kind: Kind) { + let fixture = Fixture::new(kind); + let proof = fixture.proof(); + fixture + .verify(&proof, &fixture.genuine_transition()) + .expect("expected the genuine transition to verify"); + + assert_unexpected_result( + fixture.verify( + &proof, + &fixture.transition(MINIMUM, MAXIMUM, OTHER_RECIPIENT, NOTE), + ), + "another recipient", + ); + assert_unexpected_result( + fixture.verify( + &proof, + &fixture.transition(MINIMUM, MAXIMUM, RECIPIENT, "another note"), + ), + "another note", + ); +} + +#[test] +fn should_verify_a_random_transfer_only_against_a_range_holding_the_proved_amount() { + assert_amount_checked_against_the_range(Kind::Transfer); +} + +#[test] +fn should_verify_a_random_mint_only_against_a_range_holding_the_proved_amount() { + assert_amount_checked_against_the_range(Kind::Mint); +} + +#[test] +fn should_still_compare_the_recipient_and_note_of_a_random_transfer() { + assert_other_fields_compared(Kind::Transfer); +} + +#[test] +fn should_still_compare_the_recipient_and_note_of_a_random_mint() { + assert_other_fields_compared(Kind::Mint); +} diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs index 530d1ce0e71..5d8814783d9 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs @@ -1,4 +1,4 @@ -use versioned_feature_core::FeatureVersionBounds; +use versioned_feature_core::{FeatureVersionBounds, OptionalFeatureVersion}; pub mod v1; pub mod v2; @@ -54,6 +54,14 @@ pub struct DPPStateTransitionSerializationVersions { pub token_shielded_transfer_with_shielded_fee_state_transition: FeatureVersionBounds, pub token_unshield_with_shielded_fee_state_transition: FeatureVersionBounds, pub token_purchase_from_shielded_pool_state_transition: FeatureVersionBounds, + /// A token transfer or a token mint moving a random amount: format version 1 of either, + /// which carries `random_minimum_amount`. `StateTransition::active_version_range` makes a + /// batch carrying one active from the first protocol version whose table has this `Some`. + /// `None` refuses the batch at decode, which every table does for now, protocol version + /// 15's included (it shares `STATE_TRANSITION_SERIALIZATION_VERSIONS_V3` with 14): block + /// execution does not settle the amounts yet (`settle_random_token_amounts` waits for the + /// beacon Tenderdash delivers), so a reservation would never be paid out. 0 decodes it. + pub random_token_amount: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs index 8d0841dc9be..7eef5d5e61a 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs @@ -204,4 +204,5 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V1: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + random_token_amount: None, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs index 14b740316a5..c95cede9acf 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs @@ -204,4 +204,5 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V2: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + random_token_amount: None, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs index 4a437149a7c..880a9eff169 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs @@ -223,4 +223,7 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V3: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + // Protocol version 15 shares this table: random token amounts stay refused until block + // execution settles them + random_token_amount: None, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs index 0ca426e0118..b4e13a29029 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs @@ -15,9 +15,11 @@ pub struct DriveTokenMethodVersions { /// and the credits open offers hold. pub order_escrow: DriveTokenOrderEscrowMethodVersions, /// `settle_random_token_amounts`: pays out the random token transfers and mints of a block - /// from the block's random beacon, once every transition of the block is accepted. - /// `Some(0)` from `DRIVE_TOKEN_METHOD_VERSIONS_V3` (protocol version 15), `None` in every - /// earlier table, whose protocol versions decode no random token amount. + /// from the block's random beacon, once every transition of the block is accepted, and + /// `prepay_random_token_history_entries`, the same generation's price of the history index + /// entries the settlement may create, which the transition pays. `Some(0)` from + /// `DRIVE_TOKEN_METHOD_VERSIONS_V3` (protocol version 15), `None` in every earlier table, + /// whose protocol versions decode no random token amount. pub settle_random_token_amounts: OptionalFeatureVersion, } From 722cc7fbcae903af9ea5f68c2ec2fc6f88c9ab7b Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 05:57:09 +0700 Subject: [PATCH 3/8] fix(platform): bind random token history documents to their range A random transfer or mint now writes its history document under an id that also hashes the range's minimum and maximum, so the proof of a fixed payout with the same nonce, recipient and note, or of another range, holds a document of another id and no longer verifies as the random request. - dpp: token_history_document_id is the one recipe for a token history document's id; TokenEvent::build_historical_document_owned, the transition's historical_document_id and build_historical_document take the random range (TokenTransition::random_amount_range). Every other event keeps the id it always had. - drive: random transfers and mints write their history through the new TokenRandomAmountHistory operation; add_token_transaction_history_operations, the settlement and the history prepayment derive the id from the helper. - The setter of the random minimum moves off the shared methods traits onto the versioned TokenTransferTransition and TokenMintTransition, which take the format holding it; a bare version 0 transition has no setter that would drop the value. - Proof tests: another range, even one holding the drawn amount, and a fixed payout's proof no longer verify as the random transition. Co-Authored-By: Claude Opus 5.5 --- .../token_mint_transition/v0/v0_methods.rs | 14 +- .../token_mint_transition/v0_methods.rs | 41 ++-- .../token_mint_transition/v1/v1_methods.rs | 4 - .../v0/v0_methods.rs | 15 +- .../token_transfer_transition/v0_methods.rs | 47 ++-- .../batched_transition/token_transition.rs | 32 ++- .../batch_transition/v1/v1_methods.rs | 2 - .../rs-dpp/src/tokens/history_document_id.rs | 75 +++++++ packages/rs-dpp/src/tokens/mod.rs | 3 + packages/rs-dpp/src/tokens/token_event.rs | 14 +- .../add_transaction_history_operations/mod.rs | 8 +- .../v0/mod.rs | 3 + .../tokens/settle_random_token_amounts/mod.rs | 4 + .../settle_random_token_amounts/v0/mod.rs | 25 ++- .../batch/token/token_mint_transition.rs | 37 +++- .../batch/token/token_transfer_transition.rs | 36 +++- .../token_transition/mod.rs | 32 ++- .../v0/transformer.rs | 1 + .../v0/transformer.rs | 1 + .../src/util/batch/drive_op_batch/mod.rs | 3 +- .../src/util/batch/drive_op_batch/token.rs | 39 ++++ .../v0/group_burn_tests.rs | 1 + .../v0/mod.rs | 19 +- .../v0/random_amount_tests.rs | 202 ++++++++++++++---- 24 files changed, 492 insertions(+), 166 deletions(-) create mode 100644 packages/rs-dpp/src/tokens/history_document_id.rs diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0/v0_methods.rs index bfb601fbbf2..b6abb2dff6a 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0/v0_methods.rs @@ -35,16 +35,10 @@ pub trait TokenMintTransitionV0Methods: /// The least the mint issues when its amount is random (format version 1, protocol version /// 15): the recipient receives an amount drawn uniformly from it to `amount`, both included. - /// `None` for a mint of exactly `amount`, which every version 0 mint is. + /// `None` for a mint of exactly `amount`, which every version 0 mint is. Set on the + /// versioned [`TokenMintTransition`], which takes the format holding it. fn random_minimum_amount(&self) -> Option; - /// Sets the least the mint issues when its amount is random. On a - /// [`TokenMintTransition`] the transition takes the format that holds the value: `Some` - /// turns a version 0 mint into a version 1 one, and `None` turns a version 1 mint back into - /// version 0, the format every protocol version accepts. A bare `TokenMintTransitionV0` - /// holds no random amount, so it ignores the call. - fn set_random_minimum_amount(&mut self, random_minimum_amount: Option); - /// Returns the `public_note` field of the `TokenMintTransitionV0`. fn public_note(&self) -> Option<&String>; @@ -78,10 +72,6 @@ impl TokenMintTransitionV0Methods for TokenMintTransitionV0 { None } - fn set_random_minimum_amount(&mut self, _random_minimum_amount: Option) { - // The version 0 format holds no random amount; the enum converts to version 1 - } - fn public_note(&self) -> Option<&String> { self.public_note.as_ref() } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0_methods.rs index c39879fafae..80e2e57f560 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v0_methods.rs @@ -34,6 +34,30 @@ impl TokenBaseTransitionAccessors for TokenMintTransition { } } +impl TokenMintTransition { + /// Sets the least the mint issues when its amount is random, taking the format that holds + /// it: `Some` turns a version 0 mint into a version 1 one, and `None` turns a version 1 mint + /// back into version 0, the format every protocol version accepts. Only the versioned mint + /// has the setter: a bare `TokenMintTransitionV0` holds no random amount, so a setter on it + /// could only drop the value. + pub fn set_random_minimum_amount(&mut self, random_minimum_amount: Option) { + let current = std::mem::take(self); + *self = match (current, random_minimum_amount) { + (TokenMintTransition::V0(v0), None) => TokenMintTransition::V0(v0), + (TokenMintTransition::V0(v0), Some(minimum)) => { + TokenMintTransition::V1(TokenMintTransitionV1::from_v0(v0, Some(minimum))) + } + (TokenMintTransition::V1(mut v1), Some(minimum)) => { + v1.random_minimum_amount = Some(minimum); + TokenMintTransition::V1(v1) + } + (TokenMintTransition::V1(v1), None) => { + TokenMintTransition::V0(v1.into_v0_and_random_minimum_amount().0) + } + }; + } +} + impl TokenMintTransitionV0Methods for TokenMintTransition { fn amount(&self) -> u64 { match self { @@ -56,23 +80,6 @@ impl TokenMintTransitionV0Methods for TokenMintTransition { } } - fn set_random_minimum_amount(&mut self, random_minimum_amount: Option) { - let current = std::mem::take(self); - *self = match (current, random_minimum_amount) { - (TokenMintTransition::V0(v0), None) => TokenMintTransition::V0(v0), - (TokenMintTransition::V0(v0), Some(minimum)) => { - TokenMintTransition::V1(TokenMintTransitionV1::from_v0(v0, Some(minimum))) - } - (TokenMintTransition::V1(mut v1), Some(minimum)) => { - v1.random_minimum_amount = Some(minimum); - TokenMintTransition::V1(v1) - } - (TokenMintTransition::V1(v1), None) => { - TokenMintTransition::V0(v1.into_v0_and_random_minimum_amount().0) - } - }; - } - fn public_note(&self) -> Option<&String> { match self { TokenMintTransition::V0(v0) => v0.public_note(), diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/v1_methods.rs index 8291b5c4a46..6ad746d79d5 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/v1/v1_methods.rs @@ -40,10 +40,6 @@ impl TokenMintTransitionV0Methods for TokenMintTransitionV1 { self.random_minimum_amount } - fn set_random_minimum_amount(&mut self, random_minimum_amount: Option) { - self.random_minimum_amount = random_minimum_amount; - } - fn public_note(&self) -> Option<&String> { self.public_note.as_ref() } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0/v0_methods.rs index c81da529953..a30f4f8201c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0/v0_methods.rs @@ -29,16 +29,11 @@ pub trait TokenTransferTransitionV0Methods: TokenBaseTransitionAccessors { /// The least the transfer moves when its amount is random (format version 1, protocol /// version 15): the recipient receives an amount drawn uniformly from it to `amount`, both /// included. `None` for a transfer of exactly `amount`, which every version 0 transfer is. + /// Set on the versioned + /// [`TokenTransferTransition`](crate::state_transition::batch_transition::TokenTransferTransition), + /// which takes the format holding it. fn random_minimum_amount(&self) -> Option; - /// Sets the least the transfer moves when its amount is random. On a - /// [`TokenTransferTransition`](crate::state_transition::batch_transition::TokenTransferTransition) - /// the transition takes the format that holds the value: `Some` turns a version 0 transfer - /// into a version 1 one, and `None` turns a version 1 transfer back into version 0, the - /// format every protocol version accepts. A bare `TokenTransferTransitionV0` holds no random - /// amount, so it ignores the call. - fn set_random_minimum_amount(&mut self, random_minimum_amount: Option); - /// Returns the `recipient_owner_id` field of the `TokenTransferTransitionV0`. fn recipient_id(&self) -> Identifier; @@ -125,10 +120,6 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransitionV0 { None } - fn set_random_minimum_amount(&mut self, _random_minimum_amount: Option) { - // The version 0 format holds no random amount; the enum converts to version 1 - } - fn recipient_id(&self) -> Identifier { self.recipient_id } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0_methods.rs index 50c2e43bc10..d32ed4dfd71 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/v0_methods.rs @@ -30,6 +30,33 @@ impl TokenBaseTransitionAccessors for TokenTransferTransition { } } +impl TokenTransferTransition { + /// Sets the least the transfer moves when its amount is random, taking the format that + /// holds it: `Some` turns a version 0 transfer into a version 1 one, and `None` turns a + /// version 1 transfer back into version 0, the format every protocol version accepts. Only + /// the versioned transfer has the setter: a bare `TokenTransferTransitionV0` holds no random + /// amount, so a setter on it could only drop the value. + pub fn set_random_minimum_amount(&mut self, random_minimum_amount: Option) { + let current = std::mem::replace( + self, + TokenTransferTransition::V0(TokenTransferTransitionV0::default()), + ); + *self = match (current, random_minimum_amount) { + (TokenTransferTransition::V0(v0), None) => TokenTransferTransition::V0(v0), + (TokenTransferTransition::V0(v0), Some(minimum)) => { + TokenTransferTransition::V1(TokenTransferTransitionV1::from_v0(v0, Some(minimum))) + } + (TokenTransferTransition::V1(mut v1), Some(minimum)) => { + v1.random_minimum_amount = Some(minimum); + TokenTransferTransition::V1(v1) + } + (TokenTransferTransition::V1(v1), None) => { + TokenTransferTransition::V0(v1.into_v0_and_random_minimum_amount().0) + } + }; + } +} + impl TokenTransferTransitionV0Methods for TokenTransferTransition { fn amount(&self) -> u64 { match self { @@ -52,26 +79,6 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransition { } } - fn set_random_minimum_amount(&mut self, random_minimum_amount: Option) { - let current = std::mem::replace( - self, - TokenTransferTransition::V0(TokenTransferTransitionV0::default()), - ); - *self = match (current, random_minimum_amount) { - (TokenTransferTransition::V0(v0), None) => TokenTransferTransition::V0(v0), - (TokenTransferTransition::V0(v0), Some(minimum)) => { - TokenTransferTransition::V1(TokenTransferTransitionV1::from_v0(v0, Some(minimum))) - } - (TokenTransferTransition::V1(mut v1), Some(minimum)) => { - v1.random_minimum_amount = Some(minimum); - TokenTransferTransition::V1(v1) - } - (TokenTransferTransition::V1(v1), None) => { - TokenTransferTransition::V0(v1.into_v0_and_random_minimum_amount().0) - } - }; - } - fn recipient_id(&self) -> Identifier { match self { TokenTransferTransition::V0(v0) => v0.recipient_id, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs index 6e31dd1f946..1d425f53136 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs @@ -55,6 +55,7 @@ use crate::state_transition::batch_transition::token_direct_purchase_to_pool_tra use crate::state_transition::batch_transition::token_unshield_transition::v0::v0_methods::TokenUnshieldTransitionV0Methods; use crate::state_transition::batch_transition::token_unfreeze_transition::v0::v0_methods::TokenUnfreezeTransitionV0Methods; use crate::tokens::token_event::TokenEvent; +use crate::tokens::token_history_document_id; pub const TOKEN_HISTORY_ID_BYTES: [u8; 32] = [ 45, 67, 89, 21, 34, 216, 145, 78, 156, 243, 17, 58, 202, 190, 13, 92, 61, 40, 122, 201, 84, 99, @@ -523,7 +524,11 @@ pub trait TokenTransitionV0Methods { &self, token_history_contract: &'a DataContract, ) -> Result, ProtocolError>; - /// Historical document id + /// The least and the most a random transfer or mint moves (format version 1, protocol + /// version 15); `None` for any other transition, which moves a fixed amount or none. + fn random_amount_range(&self) -> Option<(TokenAmount, TokenAmount)>; + /// Historical document id (`token_history_document_id`), which for a random transfer or + /// mint also hashes the range it moves an amount from fn historical_document_id(&self, owner_id: Identifier) -> Identifier; fn associated_token_event( &self, @@ -730,16 +735,26 @@ impl TokenTransitionV0Methods for TokenTransition { Ok(token_history_contract.document_type_for_name(self.historical_document_type_name())?) } + fn random_amount_range(&self) -> Option<(TokenAmount, TokenAmount)> { + match self { + TokenTransition::Transfer(transfer) => transfer + .random_minimum_amount() + .map(|minimum| (minimum, transfer.amount())), + TokenTransition::Mint(mint) => mint + .random_minimum_amount() + .map(|minimum| (minimum, mint.amount())), + _ => None, + } + } + /// Historical document id fn historical_document_id(&self, owner_id: Identifier) -> Identifier { - let token_id = self.token_id(); - let name = self.historical_document_type_name(); - let owner_nonce = self.identity_contract_nonce(); - Document::generate_document_id_v0( - &token_id, + token_history_document_id( + &self.token_id(), &owner_id, - format!("history_{}", name).as_str(), - owner_nonce.to_be_bytes().as_slice(), + self.historical_document_type_name(), + self.identity_contract_nonce(), + self.random_amount_range(), ) } @@ -758,6 +773,7 @@ impl TokenTransitionV0Methods for TokenTransition { token_id, owner_id, owner_nonce, + self.random_amount_range(), block_info, platform_version, ) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs index 3e836df0d44..fe197874649 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs @@ -63,13 +63,11 @@ use crate::state_transition::batch_transition::token_freeze_transition::TokenFre #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV0; #[cfg(feature = "state-transition-signing")] -use crate::state_transition::batch_transition::token_mint_transition::v0::v0_methods::TokenMintTransitionV0Methods; #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::token_set_price_for_direct_purchase_transition::TokenSetPriceForDirectPurchaseTransitionV0; #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV0; #[cfg(feature = "state-transition-signing")] -use crate::state_transition::batch_transition::token_transfer_transition::v0::v0_methods::TokenTransferTransitionV0Methods; #[cfg(feature = "state-transition-signing")] use crate::shielded::OrchardBundleParams; #[cfg(feature = "state-transition-signing")] diff --git a/packages/rs-dpp/src/tokens/history_document_id.rs b/packages/rs-dpp/src/tokens/history_document_id.rs new file mode 100644 index 00000000000..1e072a9f6de --- /dev/null +++ b/packages/rs-dpp/src/tokens/history_document_id.rs @@ -0,0 +1,75 @@ +//! The id of the document a token transition writes into the token history contract. + +use crate::balances::credits::TokenAmount; +use crate::document::Document; +use crate::prelude::IdentityNonce; +use platform_value::Identifier; + +/// The id of the history document of a token event of `document_type_name` (one of the token +/// history contract's types, `TokenEvent::associated_document_type_name`) that the transition +/// of `owner_id` with `owner_nonce` on `token_id` writes. The writer, the settlement of a +/// random amount and the proof verifier all find the document by it. +/// +/// A random transfer or mint (protocol version 15) passes `random_range`, the least and the +/// most it moves: its id hashes both after the nonce, so the history document of a transfer +/// or mint of a fixed amount, or of one drawn from another range, has another id and can not +/// stand for it in a proof. Every other event passes `None` and keeps the id it always had. +pub fn token_history_document_id( + token_id: &Identifier, + owner_id: &Identifier, + document_type_name: &str, + owner_nonce: IdentityNonce, + random_range: Option<(TokenAmount, TokenAmount)>, +) -> Identifier { + let mut entropy = owner_nonce.to_be_bytes().to_vec(); + if let Some((minimum, maximum)) = random_range { + entropy.extend_from_slice(&minimum.to_be_bytes()); + entropy.extend_from_slice(&maximum.to_be_bytes()); + } + Document::generate_document_id_v0( + token_id, + owner_id, + format!("history_{}", document_type_name).as_str(), + entropy.as_slice(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TOKEN: [u8; 32] = [1; 32]; + const OWNER: [u8; 32] = [2; 32]; + + fn id(random_range: Option<(TokenAmount, TokenAmount)>) -> Identifier { + token_history_document_id( + &Identifier::from(TOKEN), + &Identifier::from(OWNER), + "transfer", + 7, + random_range, + ) + } + + #[test] + fn should_keep_the_id_of_an_event_without_a_random_amount() { + assert_eq!( + id(None), + Document::generate_document_id_v0( + &Identifier::from(TOKEN), + &Identifier::from(OWNER), + "history_transfer", + 7u64.to_be_bytes().as_slice(), + ) + ); + } + + #[test] + fn should_give_a_random_amount_an_id_of_its_own_range() { + let random = id(Some((10, 500))); + assert_ne!(random, id(None)); + assert_ne!(random, id(Some((10, 501)))); + assert_ne!(random, id(Some((11, 500)))); + assert_eq!(random, id(Some((10, 500)))); + } +} diff --git a/packages/rs-dpp/src/tokens/mod.rs b/packages/rs-dpp/src/tokens/mod.rs index b827b2c0dcf..b42cef1fef1 100644 --- a/packages/rs-dpp/src/tokens/mod.rs +++ b/packages/rs-dpp/src/tokens/mod.rs @@ -9,6 +9,7 @@ pub mod contract_info; pub mod emergency_action; pub mod errors; pub mod gas_fees_paid_by; +mod history_document_id; pub mod info; pub mod status; pub mod token_amount_on_contract_token; @@ -16,6 +17,8 @@ pub mod token_event; pub mod token_payment_info; pub mod token_pricing_schedule; +pub use history_document_id::token_history_document_id; + pub const MAX_TOKEN_NOTE_LEN: usize = 2048; pub type SharedEncryptedNote = (SenderKeyIndex, RecipientKeyIndex, Vec); pub type PrivateEncryptedNote = ( diff --git a/packages/rs-dpp/src/tokens/token_event.rs b/packages/rs-dpp/src/tokens/token_event.rs index 32e5bf396ec..1f730066572 100644 --- a/packages/rs-dpp/src/tokens/token_event.rs +++ b/packages/rs-dpp/src/tokens/token_event.rs @@ -35,7 +35,7 @@ pub type TokenEventPersonalEncryptedNote = Option<( use crate::serialization::PlatformSerializableWithPlatformVersion; use crate::tokens::emergency_action::TokenEmergencyAction; use crate::tokens::token_pricing_schedule::TokenPricingSchedule; -use crate::tokens::SharedEncryptedNote; +use crate::tokens::{token_history_document_id, SharedEncryptedNote}; use crate::ProtocolError; /// Alias representing the identity that will receive tokens or other effects from a token operation. @@ -850,19 +850,25 @@ impl TokenEvent { Ok(token_history_contract.document_type_for_name(self.associated_document_type_name())?) } + /// The history document of the event, recorded by the transition of `owner_id` with + /// `owner_nonce` on `token_id`, under the id `token_history_document_id` gives it. + /// `random_range` is the least and the most a random transfer or mint moves (protocol + /// version 15), and `None` for every other event. pub fn build_historical_document_owned( self, token_id: Identifier, owner_id: Identifier, owner_nonce: IdentityNonce, + random_range: Option<(TokenAmount, TokenAmount)>, block_info: &BlockInfo, platform_version: &PlatformVersion, ) -> Result { - let document_id = Document::generate_document_id_v0( + let document_id = token_history_document_id( &token_id, &owner_id, - format!("history_{}", self.associated_document_type_name()).as_str(), - owner_nonce.to_be_bytes().as_slice(), + self.associated_document_type_name(), + owner_nonce, + random_range, ); let properties = match self { diff --git a/packages/rs-drive/src/drive/tokens/add_transaction_history_operations/mod.rs b/packages/rs-drive/src/drive/tokens/add_transaction_history_operations/mod.rs index 5ea9123f862..911840e67e9 100644 --- a/packages/rs-drive/src/drive/tokens/add_transaction_history_operations/mod.rs +++ b/packages/rs-drive/src/drive/tokens/add_transaction_history_operations/mod.rs @@ -3,6 +3,7 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::util::estimated_layers::EstimatedLayers; +use dpp::balances::credits::TokenAmount; use dpp::block::block_info::BlockInfo; use dpp::identifier::Identifier; use dpp::prelude::IdentityNonce; @@ -13,7 +14,10 @@ use platform_version::version::PlatformVersion; mod v0; impl Drive { - /// Adds token transaction history + /// Adds token transaction history: the history document of `event`, under the id + /// `token_history_document_id` gives it. `random_range` is the least and the most a random + /// transfer or mint moves (protocol version 15), whose id also hashes them, and `None` for + /// every other event, whose id is the one it always had. #[allow(clippy::too_many_arguments)] pub fn add_token_transaction_history_operations( &self, @@ -21,6 +25,7 @@ impl Drive { owner_id: Identifier, owner_nonce: IdentityNonce, event: TokenEvent, + random_range: Option<(TokenAmount, TokenAmount)>, block_info: &BlockInfo, estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, @@ -38,6 +43,7 @@ impl Drive { owner_id, owner_nonce, event, + random_range, block_info, estimated_costs_only_with_layer_info, transaction, diff --git a/packages/rs-drive/src/drive/tokens/add_transaction_history_operations/v0/mod.rs b/packages/rs-drive/src/drive/tokens/add_transaction_history_operations/v0/mod.rs index 4d956ca4da3..810f147fc31 100644 --- a/packages/rs-drive/src/drive/tokens/add_transaction_history_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/add_transaction_history_operations/v0/mod.rs @@ -4,6 +4,7 @@ use crate::fees::op::LowLevelDriveOperation; use crate::util::estimated_layers::EstimatedLayers; use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use dpp::balances::credits::TokenAmount; use dpp::block::block_info::BlockInfo; use dpp::identifier::Identifier; use dpp::prelude::IdentityNonce; @@ -20,6 +21,7 @@ impl Drive { owner_id: Identifier, owner_nonce: IdentityNonce, event: TokenEvent, + random_range: Option<(TokenAmount, TokenAmount)>, block_info: &BlockInfo, estimated_costs_only_with_layer_info: &mut Option, transaction: TransactionArg, @@ -36,6 +38,7 @@ impl Drive { token_id, owner_id, owner_nonce, + random_range, block_info, platform_version, )?; diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs index c328db99a51..1cf475a80bf 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs @@ -173,6 +173,8 @@ impl Drive { /// history document. /// - `event`: the history event the transition records, a transfer or a mint of the range's /// minimum. + /// - `random_range`: the least and the most the transition moves, which the history + /// document's id hashes (`token_history_document_id`). /// - `block_info`: the block, whose epoch prices the entries. /// - `transaction`: the block's transaction. /// - `platform_version`: selects the method version; `None` before protocol version 15, @@ -187,6 +189,7 @@ impl Drive { owner_id: Identifier, identity_contract_nonce: IdentityNonce, event: TokenEvent, + random_range: (TokenAmount, TokenAmount), block_info: &BlockInfo, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -202,6 +205,7 @@ impl Drive { owner_id, identity_contract_nonce, event, + random_range, block_info, transaction, platform_version, diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs index 74a9949ce2a..1c35febca56 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs @@ -21,6 +21,7 @@ use dpp::document::{Document, DocumentV0Getters}; use dpp::fee::fee_result::FeeResult; use dpp::prelude::{DataContract, Identifier, IdentityNonce}; use dpp::tokens::token_event::TokenEvent; +use dpp::tokens::token_history_document_id; use dpp::version::PlatformVersion; use grovedb::{Element, TransactionArg}; use std::borrow::Cow; @@ -179,6 +180,7 @@ impl Drive { owner_id: Identifier, identity_contract_nonce: IdentityNonce, event: TokenEvent, + random_range: (TokenAmount, TokenAmount), block_info: &BlockInfo, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -193,6 +195,7 @@ impl Drive { token_id, owner_id, identity_contract_nonce, + Some(random_range), block_info, platform_version, )?; @@ -216,8 +219,8 @@ impl Drive { /// The operations rewriting the history document of `request`'s transition to hold /// `amount`, none when the token keeps no history of the kind: the transition then wrote no - /// history document. The document is found the way it was written - /// (`TokenEvent::build_historical_document_owned`), and rewritten in place with its own + /// history document. The document is found by the id it was written under, which hashes the + /// request's range (`token_history_document_id`), and rewritten in place with its own /// storage flags, its `byAmount` index entry moved to the drawn amount. fn random_token_history_amount_operations_v0( &self, @@ -240,11 +243,12 @@ impl Drive { "the token history contract defines the transfer and mint types", )) })?; - let document_id = Document::generate_document_id_v0( + let document_id = token_history_document_id( &request.token_id, &request.owner_id, - format!("history_{}", document_type_name).as_str(), - request.identity_contract_nonce.to_be_bytes().as_slice(), + document_type_name, + request.identity_contract_nonce, + Some((request.minimum, request.maximum)), ); let primary_key_path = @@ -445,11 +449,13 @@ mod tests { let mut operations = vec![DriveOperation::TokenOperation(reservation)]; if with_history { operations.push(DriveOperation::TokenOperation( - TokenOperationType::TokenHistory { + TokenOperationType::TokenRandomAmountHistory { token_id, owner_id: request.owner_id, nonce: request.identity_contract_nonce, event, + minimum: request.minimum, + maximum: request.maximum, }, )); } @@ -528,11 +534,12 @@ mod tests { let document_type = contract .document_type_for_name(document_type_name) .expect("expected the history type"); - let document_id = Document::generate_document_id_v0( + let document_id = token_history_document_id( &request.token_id, &request.owner_id, - format!("history_{}", document_type_name).as_str(), - request.identity_contract_nonce.to_be_bytes().as_slice(), + document_type_name, + request.identity_contract_nonce, + Some((request.minimum, request.maximum)), ); let path = contract_documents_primary_key_path(contract.id_ref().as_bytes(), document_type_name); diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_mint_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_mint_transition.rs index 8a7759646a7..6265815a2c8 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_mint_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_mint_transition.rs @@ -114,15 +114,34 @@ impl DriveHighLevelBatchOperationConverter for TokenMintTransitionAction { let token_configuration = self.base().token_configuration()?; if token_configuration.keeps_history().keeps_minting_history() { - ops.push(TokenOperation(TokenOperationType::TokenHistory { - token_id: self.token_id(), - owner_id, - nonce: identity_contract_nonce, - event: TokenEvent::Mint( - history_amount, - self.identity_balance_holder_id(), - self.public_note_owned(), - ), + let token_id = self.token_id(); + let random_range = self + .random_minimum_amount() + .map(|minimum| (minimum, self.mint_amount())); + let event = TokenEvent::Mint( + history_amount, + self.identity_balance_holder_id(), + self.public_note_owned(), + ); + // A random mint's history document id also hashes its range, so a + // proof of the document binds the range the amount was drawn from + ops.push(TokenOperation(match random_range { + Some((minimum, maximum)) => { + TokenOperationType::TokenRandomAmountHistory { + token_id, + owner_id, + nonce: identity_contract_nonce, + event, + minimum, + maximum, + } + } + None => TokenOperationType::TokenHistory { + token_id, + owner_id, + nonce: identity_contract_nonce, + event, + }, })); } } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transfer_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transfer_transition.rs index 125f95a1060..45638ea1077 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transfer_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transfer_transition.rs @@ -78,21 +78,35 @@ impl DriveHighLevelBatchOperationConverter for TokenTransferTransitionAction { } }; + let random_minimum_amount = self.random_minimum_amount(); let token_configuration = self.base().token_configuration()?; if token_configuration.keeps_history().keeps_transfer_history() { let (public_note, shared_encrypted_note, private_encrypted_note) = self.notes_owned(); - ops.push(TokenOperation(TokenOperationType::TokenHistory { - token_id, - owner_id, - nonce: identity_contract_nonce, - event: TokenEvent::Transfer( - recipient_id, - public_note, - shared_encrypted_note, - private_encrypted_note, - history_amount, - ), + let event = TokenEvent::Transfer( + recipient_id, + public_note, + shared_encrypted_note, + private_encrypted_note, + history_amount, + ); + // A random transfer's history document id also hashes its range, so a + // proof of the document binds the range the amount was drawn from + ops.push(TokenOperation(match random_minimum_amount { + Some(minimum) => TokenOperationType::TokenRandomAmountHistory { + token_id, + owner_id, + nonce: identity_contract_nonce, + event, + minimum, + maximum: amount, + }, + None => TokenOperationType::TokenHistory { + token_id, + owner_id, + nonce: identity_contract_nonce, + event, + }, })); } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs index 38d4b451267..c8dcf6248eb 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs @@ -51,7 +51,9 @@ pub mod token_claim_to_pool_transition_action; pub mod token_direct_purchase_to_pool_transition_action; use derive_more::From; +use dpp::balances::credits::TokenAmount; use dpp::block::block_info::BlockInfo; +use dpp::tokens::token_history_document_id; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Getters; @@ -214,16 +216,29 @@ impl TokenTransitionAction { Ok(token_history_contract.document_type_for_name(self.historical_document_type_name())?) } - /// Historical document id + /// The least and the most a random transfer or mint moves (protocol version 15); `None` + /// for any other action, which moves a fixed amount or none. + pub fn random_amount_range(&self) -> Option<(TokenAmount, TokenAmount)> { + match self { + TokenTransitionAction::TransferAction(transfer) => transfer + .random_minimum_amount() + .map(|minimum| (minimum, transfer.amount())), + TokenTransitionAction::MintAction(mint) => mint + .random_minimum_amount() + .map(|minimum| (minimum, mint.mint_amount())), + _ => None, + } + } + + /// Historical document id (`token_history_document_id`), which for a random transfer or + /// mint also hashes the range it moves an amount from pub fn historical_document_id(&self, owner_id: Identifier) -> Identifier { - let token_id = self.base().token_id(); - let name = self.historical_document_type_name(); - let owner_nonce = self.base().identity_contract_nonce(); - Document::generate_document_id_v0( - &token_id, + token_history_document_id( + &self.base().token_id(), &owner_id, - format!("history_{}", name).as_str(), - owner_nonce.to_be_bytes().as_slice(), + self.historical_document_type_name(), + self.base().identity_contract_nonce(), + self.random_amount_range(), ) } @@ -241,6 +256,7 @@ impl TokenTransitionAction { token_id, owner_id, owner_nonce, + self.random_amount_range(), block_info, platform_version, ) diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs index dc022c143b3..38f27fd536b 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs @@ -462,6 +462,7 @@ impl TokenMintTransitionActionV0 { owner_id, mint.base.identity_contract_nonce(), history_event, + (minimum, mint.mint_amount), block_info, transaction, platform_version, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs index 19e11ff7d9d..b7d8566d70b 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs @@ -250,6 +250,7 @@ impl TokenTransferTransitionActionV0 { owner_id, action.base.identity_contract_nonce(), history_event, + (minimum, action.amount), block_info, transaction, platform_version, diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index 97291777b6c..7732bf030d5 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -674,7 +674,8 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { | TokenOperationType::TokenMarkPerpetualReleaseAsDistributed { .. } | TokenOperationType::TokenMarkPreProgrammedReleaseAsDistributed { .. } | TokenOperationType::TokenMarkOncePerIdentityReleaseAsDistributed { .. } - | TokenOperationType::TokenHistory { .. } => vec![], + | TokenOperationType::TokenHistory { .. } + | TokenOperationType::TokenRandomAmountHistory { .. } => vec![], } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index 1ef11021aee..d6704272632 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -229,6 +229,23 @@ pub enum TokenOperationType { /// Should we allow this to be the first ever mint allow_first_mint: bool, }, + /// Adds the historical document of a random token transfer or mint (protocol version 15), + /// whose id also hashes the range the amount is drawn from + /// (`token_history_document_id`), so that a proof of the document binds the range. + TokenRandomAmountHistory { + /// The token id + token_id: Identifier, + /// The identity making the event + owner_id: Identifier, + /// The nonce + nonce: IdentityNonce, + /// The token event, a transfer or a mint of `minimum` until the settlement + event: TokenEvent, + /// The least the transition moves + minimum: TokenAmount, + /// The most the transition moves + maximum: TokenAmount, + }, } impl DriveLowLevelOperationConverter for TokenOperationType { @@ -328,6 +345,28 @@ impl DriveLowLevelOperationConverter for TokenOperationType { owner_id, nonce, event, + None, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + Ok(batch_operations) + } + TokenOperationType::TokenRandomAmountHistory { + token_id, + owner_id, + nonce, + event, + minimum, + maximum, + } => { + let batch_operations = drive.add_token_transaction_history_operations( + token_id, + owner_id, + nonce, + event, + Some((minimum, maximum)), block_info, estimated_costs_only_with_layer_info, transaction, diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/group_burn_tests.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/group_burn_tests.rs index 82229023160..2ec5f70291a 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/group_burn_tests.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/group_burn_tests.rs @@ -218,6 +218,7 @@ impl Fixture { signer, 1, TokenEvent::Burn(burn_amount, PROPOSER.into(), None), + None, &BlockInfo::default(), &mut None, None, diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 3c4bf1ece4d..9672393b97f 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -683,17 +683,14 @@ impl Drive { // its sender cannot know: the history holds the amount drawn from // the block's random beacon (the range's minimum until the draw), // so the amount is taken from the proved document once it lies - // in the range the transition asked for. A transition without a - // random amount compares it as before. - let random_amount_range = match token_transition { - TokenTransition::Transfer(transfer) => transfer - .random_minimum_amount() - .map(|minimum| minimum..=transfer.amount()), - TokenTransition::Mint(mint) => mint - .random_minimum_amount() - .map(|minimum| minimum..=mint.amount()), - _ => None, - }; + // in the range the transition asked for. The document's id + // hashes that range (`token_history_document_id`), so the proof + // is of a document written for this range, not of a fixed + // amount or another range. A transition without a random amount + // compares it as before. + let random_amount_range = token_transition + .random_amount_range() + .map(|(minimum, maximum)| minimum..=maximum); if let Some(range) = &random_amount_range { let amount: TokenAmount = document.properties().get_integer("amount")?; diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs index 0f824eb1f34..e4a6546d90f 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs @@ -1,8 +1,10 @@ //! The proof of a random token transfer or mint (protocol version 15): its sender cannot know //! the amount the block's random beacon drew, so verification takes it from the proved history //! document once it lies in the range the transition asked for, and compares every other field -//! as for any transition. Each test verifies one genuine proof, built from a drive that executed -//! and settled the transition, against expectations that differ from the transition sent. +//! as for any transition. The document's id hashes the range (`token_history_document_id`), so +//! the proof binds the range it was drawn from. Each test verifies one genuine proof, built from +//! a drive that executed the transition (and settled it, for a random one), against +//! expectations that differ from the transition sent. use super::*; use crate::drive::tokens::settle_random_token_amounts::{ @@ -22,8 +24,12 @@ use dpp::identity::Identity; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; -use dpp::state_transition::batch_transition::token_mint_transition::TokenMintTransitionV1; -use dpp::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV1; +use dpp::state_transition::batch_transition::token_mint_transition::{ + TokenMintTransitionV0, TokenMintTransitionV1, +}; +use dpp::state_transition::batch_transition::token_transfer_transition::{ + TokenTransferTransitionV0, TokenTransferTransitionV1, +}; use dpp::state_transition::batch_transition::{ BatchTransition, BatchTransitionV1, TokenMintTransition, TokenTransferTransition, }; @@ -62,6 +68,13 @@ impl Fixture { /// from `MINIMUM` to `MAXIMUM`, sent by `OWNER` to `RECIPIENT` with `NOTE`, and settled it /// from `BEACON`: the history document holds the drawn amount. fn new(kind: Kind) -> Self { + Self::executing(kind, true) + } + + /// [`Self::new`], or, when not `random`, a drive after a block executed a `kind` of exactly + /// `MINIMUM`, sent by `OWNER` to `RECIPIENT` with `NOTE` and the same nonce + /// ([`Self::fixed_transition`]). + fn executing(kind: Kind, random: bool) -> Self { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); let mut token = TokenConfigurationV0::default_most_restrictive().with_base_supply(0); @@ -139,6 +152,60 @@ impl Fixture { ) .expect("expected to mint the base supply to the sender"); + if !random { + let (operation, event) = match kind { + Kind::Transfer => ( + TokenOperationType::TokenTransfer { + token_id, + sender_id: OWNER.into(), + recipient_id: RECIPIENT.into(), + amount: MINIMUM, + }, + TokenEvent::Transfer( + RECIPIENT.into(), + Some(NOTE.to_string()), + None, + None, + MINIMUM, + ), + ), + Kind::Mint => ( + TokenOperationType::TokenMint { + token_id, + identity_balance_holder_id: RECIPIENT.into(), + mint_amount: MINIMUM, + allow_first_mint: false, + allow_saturation: false, + }, + TokenEvent::Mint(MINIMUM, RECIPIENT.into(), Some(NOTE.to_string())), + ), + }; + drive + .apply_drive_operations( + vec![ + DriveOperation::TokenOperation(operation), + DriveOperation::TokenOperation(TokenOperationType::TokenHistory { + token_id, + owner_id: OWNER.into(), + nonce: NONCE, + event, + }), + ], + true, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("expected the block to execute the fixed transition"); + return Fixture { + drive, + contract, + token_id, + kind, + }; + } + // What the block's execution of the transition writes: the reservation, and the // history document holding the range's minimum let (reservation, event, request_kind) = match kind { @@ -181,11 +248,13 @@ impl Fixture { .apply_drive_operations( vec![ DriveOperation::TokenOperation(reservation), - DriveOperation::TokenOperation(TokenOperationType::TokenHistory { + DriveOperation::TokenOperation(TokenOperationType::TokenRandomAmountHistory { token_id, owner_id: OWNER.into(), nonce: NONCE, event, + minimum: MINIMUM, + maximum: MAXIMUM, }), ], true, @@ -272,10 +341,52 @@ impl Fixture { self.transition(MINIMUM, MAXIMUM, RECIPIENT, NOTE) } + /// The transition of the fixture's kind of exactly `MINIMUM` (format version 0), to + /// `RECIPIENT` with `NOTE`, which [`Self::executing`] executes when not random. + fn fixed_transition(&self) -> StateTransition { + let base = TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: NONCE, + token_contract_position: 0, + data_contract_id: CONTRACT.into(), + token_id: self.token_id, + using_group_info: None, + }); + let token_transition = match self.kind { + Kind::Transfer => { + TokenTransition::Transfer(TokenTransferTransition::V0(TokenTransferTransitionV0 { + base, + amount: MINIMUM, + recipient_id: RECIPIENT.into(), + public_note: Some(NOTE.to_string()), + shared_encrypted_note: None, + private_encrypted_note: None, + })) + } + Kind::Mint => TokenTransition::Mint(TokenMintTransition::V0(TokenMintTransitionV0 { + base, + issued_to_identity_id: Some(RECIPIENT.into()), + amount: MINIMUM, + public_note: Some(NOTE.to_string()), + })), + }; + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: OWNER.into(), + transitions: vec![BatchedTransition::Token(token_transition)], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + })) + } + /// The proof of the genuine transition's execution, from the drive's state. fn proof(&self) -> Vec { + self.proof_of(&self.genuine_transition()) + } + + /// The proof of `transition`'s execution, from the drive's state. + fn proof_of(&self, transition: &StateTransition) -> Vec { self.drive - .prove_state_transition(&self.genuine_transition(), None, PlatformVersion::latest()) + .prove_state_transition(transition, None, PlatformVersion::latest()) .expect("expected to prove the transition") .into_data() .expect("expected proof bytes") @@ -334,9 +445,10 @@ fn assert_unexpected_result( } } -/// The proved amount verifies against a range holding it, its bounds included, and fails -/// against a range above or below it. -fn assert_amount_checked_against_the_range(kind: Kind) { +/// The proof verifies against the range the transition was sent with, and against no other: +/// the history document's id hashes the range, so another range, even one holding the proved +/// amount, names another document, which the proof does not hold. +fn assert_bound_to_its_range(kind: Kind) { let fixture = Fixture::new(kind); let proof = fixture.proof(); let drawn = fixture.proved_amount(&proof); @@ -345,33 +457,39 @@ fn assert_amount_checked_against_the_range(kind: Kind) { "expected the settled amount {drawn} to lie in the range sent" ); - for (minimum, maximum) in [(drawn, drawn + 10), (drawn - 10, drawn), (drawn, drawn)] { - fixture - .verify( - &proof, - &fixture.transition(minimum, maximum, RECIPIENT, NOTE), - ) - .unwrap_or_else(|error| { - panic!( - "expected the range {minimum} to {maximum} holding the proved amount \ - {drawn} to verify, got {error:?}" + for (minimum, maximum) in [ + (drawn, drawn), + (MINIMUM, MAXIMUM + 1), + (MINIMUM - 1, MAXIMUM), + (drawn + 1, MAXIMUM + 10), + ] { + assert!( + fixture + .verify( + &proof, + &fixture.transition(minimum, maximum, RECIPIENT, NOTE), ) - }); + .is_err(), + "expected the range {minimum} to {maximum} not to verify against the proof of \ + {MINIMUM} to {MAXIMUM}" + ); } +} - assert_unexpected_result( - fixture.verify( - &proof, - &fixture.transition(drawn + 1, drawn + 10, RECIPIENT, NOTE), - ), - "the proved amount below the minimum", - ); - assert_unexpected_result( - fixture.verify( - &proof, - &fixture.transition(drawn - 10, drawn - 1, RECIPIENT, NOTE), - ), - "the proved amount above the maximum", +/// The proof of a transition of a fixed amount inside the range, sent with the same nonce, +/// recipient and note, verifies as that transition and not as the random one: its history +/// document's id hashes no range, so the random transition names another document. +fn assert_fixed_amount_not_taken_for_a_random_one(kind: Kind) { + let fixture = Fixture::executing(kind, false); + let proof = fixture.proof_of(&fixture.fixed_transition()); + fixture + .verify(&proof, &fixture.fixed_transition()) + .expect("expected the fixed transition to verify"); + assert!( + fixture + .verify(&proof, &fixture.genuine_transition()) + .is_err(), + "expected the proof of a fixed amount not to verify as the random transition" ); } @@ -401,13 +519,23 @@ fn assert_other_fields_compared(kind: Kind) { } #[test] -fn should_verify_a_random_transfer_only_against_a_range_holding_the_proved_amount() { - assert_amount_checked_against_the_range(Kind::Transfer); +fn should_verify_a_random_transfer_only_against_the_range_it_was_sent_with() { + assert_bound_to_its_range(Kind::Transfer); +} + +#[test] +fn should_verify_a_random_mint_only_against_the_range_it_was_sent_with() { + assert_bound_to_its_range(Kind::Mint); +} + +#[test] +fn should_not_verify_the_proof_of_a_fixed_transfer_as_a_random_one() { + assert_fixed_amount_not_taken_for_a_random_one(Kind::Transfer); } #[test] -fn should_verify_a_random_mint_only_against_a_range_holding_the_proved_amount() { - assert_amount_checked_against_the_range(Kind::Mint); +fn should_not_verify_the_proof_of_a_fixed_mint_as_a_random_one() { + assert_fixed_amount_not_taken_for_a_random_one(Kind::Mint); } #[test] From f8fcc8fdb24f1075a854c4600bf4b6ee349fbf3c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 07:19:55 +0700 Subject: [PATCH 4/8] fix(drive): refuse random token expectations the platform never executes The proof verifier now refuses a random token transfer or mint before reading the proof when the client's version does not execute random amounts, or when the transition fails the structure validation the platform refuses it with (a random group mint, a mint naming no recipient, a minimum outside 1..amount). A fixed group mint without history can then no longer stand in for a random one. Tests: a history holding an amount just outside the requested range fails the range check under the request's own history id, and either end verifies; a random group mint and an inactive version are refused. The PV15 changelog records random token amounts as inactive (note 19). Co-Authored-By: Claude Opus 5.5 --- .../v0/mod.rs | 41 ++++ .../v0/random_amount_tests.rs | 203 +++++++++++++++--- .../rs-platform-version/src/version/v15.rs | 23 +- 3 files changed, 238 insertions(+), 29 deletions(-) diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 9672393b97f..3f24fd2d798 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -34,6 +34,8 @@ use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransition use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dpp::state_transition::batch_transition::token_mint_transition::validate_structure::TokenMintTransitionStructureValidation; +use dpp::state_transition::batch_transition::token_transfer_transition::validate_structure::TokenTransferTransitionStructureValidation; use dpp::state_transition::identity_create_from_addresses_transition::accessors::IdentityCreateFromAddressesTransitionAccessorsV0; use dpp::state_transition::identity_create_transition::accessors::IdentityCreateTransitionAccessorsV0; use dpp::state_transition::identity_credit_transfer_to_addresses_transition::accessors::IdentityCreditTransferToAddressesTransitionAccessorsV0; @@ -631,6 +633,45 @@ impl Drive { )?; let keeps_historical_document = token_config.keeps_history(); + // A random transfer or mint (protocol version 15) executes only where + // its format is active and in the shape its structure validation + // admits: a random group mint, say, is refused, while a fixed one of + // the same owner, nonce, recipient and group action can stand in state + // for its proof. Expecting one the platform never executes is refused + // here, by the checks the platform refuses it with, before any branch + // reads the proof. + if token_transition.random_amount_range().is_some() { + if platform_version + .dpp + .state_transition_serialization_versions + .random_token_amount + .is_none() + { + return Err(Error::Proof(ProofError::InvalidTransition(format!( + "protocol version {} does not execute a random token transfer or mint", + platform_version.protocol_version + )))); + } + let structure = match token_transition { + TokenTransition::Transfer(transfer) => { + Some(transfer.validate_structure(owner_id, platform_version)?) + } + TokenTransition::Mint(mint) => { + Some(mint.validate_structure(platform_version)?) + } + _ => None, + }; + if let Some(error) = structure + .as_ref() + .and_then(|structure| structure.errors.first()) + { + return Err(Error::Proof(ProofError::InvalidTransition(format!( + "the platform never executes this random token transition: {}", + error + )))); + } + } + let historical_query = || { // Resolved here rather than above because the kinds that write into a // shielded pool keep no history and have no document type in the diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs index e4a6546d90f..59a801ab10b 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/random_amount_tests.rs @@ -19,6 +19,7 @@ use dpp::data_contract::associated_token::token_keeps_history_rules::v0::TokenKe use dpp::data_contract::config::v0::DataContractConfigV0; use dpp::data_contract::v1::DataContractV1; use dpp::data_contract::DataContract; +use dpp::group::GroupStateTransitionInfo; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::Identity; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; @@ -56,6 +57,29 @@ enum Kind { Mint, } +/// What the block did with the fixture's transition. +#[derive(Clone, Copy)] +enum Execution { + /// Executed the random transition and settled it from `BEACON` + Settled, + /// Executed the random transition with its history document holding `amount`, and settled + /// nothing: a history whose amount the proof's range check judges + HistoryHolding(TokenAmount), + /// Executed a transition of exactly `MINIMUM` (format version 0) in its place + Fixed, +} + +/// The latest platform version with random token amounts active, which no protocol version +/// is until block execution settles them. +fn random_platform_version() -> PlatformVersion { + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .dpp + .state_transition_serialization_versions + .random_token_amount = Some(0); + platform_version +} + struct Fixture { drive: Drive, contract: DataContract, @@ -68,13 +92,13 @@ impl Fixture { /// from `MINIMUM` to `MAXIMUM`, sent by `OWNER` to `RECIPIENT` with `NOTE`, and settled it /// from `BEACON`: the history document holds the drawn amount. fn new(kind: Kind) -> Self { - Self::executing(kind, true) + Self::executing(kind, Execution::Settled) } - /// [`Self::new`], or, when not `random`, a drive after a block executed a `kind` of exactly - /// `MINIMUM`, sent by `OWNER` to `RECIPIENT` with `NOTE` and the same nonce - /// ([`Self::fixed_transition`]). - fn executing(kind: Kind, random: bool) -> Self { + /// [`Self::new`], with the block doing `execution`: for [`Execution::Fixed`], a drive after + /// a block executed a `kind` of exactly `MINIMUM`, sent by `OWNER` to `RECIPIENT` with + /// `NOTE` and the same nonce ([`Self::fixed_transition`]). + fn executing(kind: Kind, execution: Execution) -> Self { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); let mut token = TokenConfigurationV0::default_most_restrictive().with_base_supply(0); @@ -152,7 +176,7 @@ impl Fixture { ) .expect("expected to mint the base supply to the sender"); - if !random { + if let Execution::Fixed = execution { let (operation, event) = match kind { Kind::Transfer => ( TokenOperationType::TokenTransfer { @@ -207,7 +231,11 @@ impl Fixture { } // What the block's execution of the transition writes: the reservation, and the - // history document holding the range's minimum + // history document holding the range's minimum (or the amount the fixture asks for) + let history_amount = match execution { + Execution::HistoryHolding(amount) => amount, + _ => MINIMUM, + }; let (reservation, event, request_kind) = match kind { Kind::Transfer => ( TokenOperationType::TokenReserveRandomTransfer { @@ -222,7 +250,7 @@ impl Fixture { Some(NOTE.to_string()), None, None, - MINIMUM, + history_amount, ), RandomTokenRequestKind::Transfer { sender_id: OWNER.into(), @@ -238,7 +266,7 @@ impl Fixture { maximum: MAXIMUM, allow_first_mint: false, }, - TokenEvent::Mint(MINIMUM, RECIPIENT.into(), Some(NOTE.to_string())), + TokenEvent::Mint(history_amount, RECIPIENT.into(), Some(NOTE.to_string())), RandomTokenRequestKind::Mint { recipient_id: RECIPIENT.into(), }, @@ -266,22 +294,24 @@ impl Fixture { .expect("expected the block to execute the random transition"); // The settlement draws the amount and rewrites the history document to hold it - drive - .settle_random_token_amounts( - &BEACON, - &[RandomTokenRequest { - token_id, - owner_id: OWNER.into(), - identity_contract_nonce: NONCE, - minimum: MINIMUM, - maximum: MAXIMUM, - kind: request_kind, - }], - &BlockInfo::default(), - None, - platform_version, - ) - .expect("expected the settlement to run"); + if let Execution::Settled = execution { + drive + .settle_random_token_amounts( + &BEACON, + &[RandomTokenRequest { + token_id, + owner_id: OWNER.into(), + identity_contract_nonce: NONCE, + minimum: MINIMUM, + maximum: MAXIMUM, + kind: request_kind, + }], + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected the settlement to run"); + } Self { drive, @@ -393,21 +423,62 @@ impl Fixture { } /// Verifies `proof` as the execution of `transition`, with the Merkle verifier a client - /// runs. + /// runs, at a version with random token amounts active. fn verify( &self, proof: &[u8], transition: &StateTransition, + ) -> Result<(RootHash, StateTransitionProofOutcome), Error> { + self.verify_at(proof, transition, &random_platform_version()) + } + + /// [`Self::verify`] at `platform_version`. + fn verify_at( + &self, + proof: &[u8], + transition: &StateTransition, + platform_version: &PlatformVersion, ) -> Result<(RootHash, StateTransitionProofOutcome), Error> { Drive::verify_state_transition_was_executed_with_proof( transition, &BlockInfo::default(), proof, &|_| Ok(Some(Arc::new(self.contract.clone()))), - PlatformVersion::latest(), + platform_version, ) } + /// The genuine random mint, asked for as the proposal of a group action, which a random + /// mint may not be. + fn random_group_mint_transition(&self) -> StateTransition { + let base = TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: NONCE, + token_contract_position: 0, + data_contract_id: CONTRACT.into(), + token_id: self.token_id, + using_group_info: Some(GroupStateTransitionInfo { + group_contract_position: 0, + action_id: [9; 32].into(), + action_is_proposer: true, + }), + }); + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: OWNER.into(), + transitions: vec![BatchedTransition::Token(TokenTransition::Mint( + TokenMintTransition::V1(TokenMintTransitionV1 { + base, + issued_to_identity_id: Some(RECIPIENT.into()), + amount: MAXIMUM, + public_note: Some(NOTE.to_string()), + random_minimum_amount: Some(MINIMUM), + }), + ))], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + })) + } + /// The amount the proved history document holds, read through the verification of the /// genuine transition. fn proved_amount(&self, proof: &[u8]) -> TokenAmount { @@ -432,6 +503,19 @@ impl Fixture { } } +fn assert_invalid_transition( + result: Result<(RootHash, StateTransitionProofOutcome), Error>, + expectation: &str, +) { + match result { + Err(Error::Proof(ProofError::InvalidTransition(_))) => {} + Err(error) => { + panic!("expected {expectation} to be refused as an invalid transition, got {error:?}") + } + Ok(_) => panic!("expected {expectation} to be refused, but it verified"), + } +} + fn assert_unexpected_result( result: Result<(RootHash, StateTransitionProofOutcome), Error>, expectation: &str, @@ -476,11 +560,34 @@ fn assert_bound_to_its_range(kind: Kind) { } } +/// The amount the history document of the requested range holds is checked against that +/// range: a history holding an amount just below its minimum or just above its maximum fails, +/// and one holding either end verifies with that amount. +fn assert_amount_checked_against_the_range(kind: Kind) { + for (amount, inside) in [ + (MINIMUM - 1, false), + (MINIMUM, true), + (MAXIMUM, true), + (MAXIMUM + 1, false), + ] { + let fixture = Fixture::executing(kind, Execution::HistoryHolding(amount)); + let proof = fixture.proof(); + if inside { + assert_eq!(fixture.proved_amount(&proof), amount); + } else { + assert_unexpected_result( + fixture.verify(&proof, &fixture.genuine_transition()), + "a proved amount outside the requested range", + ); + } + } +} + /// The proof of a transition of a fixed amount inside the range, sent with the same nonce, /// recipient and note, verifies as that transition and not as the random one: its history /// document's id hashes no range, so the random transition names another document. fn assert_fixed_amount_not_taken_for_a_random_one(kind: Kind) { - let fixture = Fixture::executing(kind, false); + let fixture = Fixture::executing(kind, Execution::Fixed); let proof = fixture.proof_of(&fixture.fixed_transition()); fixture .verify(&proof, &fixture.fixed_transition()) @@ -528,6 +635,46 @@ fn should_verify_a_random_mint_only_against_the_range_it_was_sent_with() { assert_bound_to_its_range(Kind::Mint); } +#[test] +fn should_check_the_proved_amount_of_a_random_transfer_against_its_range() { + assert_amount_checked_against_the_range(Kind::Transfer); +} + +#[test] +fn should_check_the_proved_amount_of_a_random_mint_against_its_range() { + assert_amount_checked_against_the_range(Kind::Mint); +} + +#[test] +fn should_refuse_a_random_group_mint_before_reading_the_proof() { + // The platform refuses a random group mint, so no proof can show one executed: a fixed + // group mint of the same owner, nonce, recipient and action would otherwise stand for it. + // The refusal comes before any proof is read, so the proof here is any proof. + let fixture = Fixture::new(Kind::Mint); + let proof = fixture.proof(); + assert_invalid_transition( + fixture.verify(&proof, &fixture.random_group_mint_transition()), + "a random group mint", + ); +} + +#[test] +fn should_refuse_a_random_transfer_or_mint_where_random_amounts_are_inactive() { + let mut inactive = PlatformVersion::latest().clone(); + inactive + .dpp + .state_transition_serialization_versions + .random_token_amount = None; + for kind in [Kind::Transfer, Kind::Mint] { + let fixture = Fixture::new(kind); + let proof = fixture.proof(); + assert_invalid_transition( + fixture.verify_at(&proof, &fixture.genuine_transition(), &inactive), + "a random transition where random amounts are inactive", + ); + } +} + #[test] fn should_not_verify_the_proof_of_a_fixed_transfer_as_a_random_one() { assert_fixed_amount_not_taken_for_a_random_one(Kind::Transfer); diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index e891c3b362b..3e18f758949 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -418,9 +418,30 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// extended in place (the document create and replace transformers, the parser's read /// checks, the reference validation's referenced side) are inert at every version, where /// no type declares such a function. +/// +/// 19. **Token amounts drawn at random, inactive**: format version 1 of the token transfer and +/// the token mint (`TokenTransferTransitionV1`, `TokenMintTransitionV1`) carries +/// `random_minimum_amount`: the amount that reaches the recipient is drawn from it to +/// `amount`, both included, out of the block's random beacon once the block's transitions +/// are accepted. Structure validation holds `1 <= minimum < amount` +/// (`InvalidTokenAmountError`) and refuses a random mint that is a group action or names +/// no recipient (`RandomTokenMintNotAllowedError`, 10462). Executing one reserves `amount` +/// in the token marketplace escrow of the token (a transfer takes it off the sender, a mint +/// adds it to the supply), writes the recipient's balance as it stands, and writes the +/// history document holding the minimum under an id that also hashes the minimum and the +/// maximum (`token_history_document_id`), so a proof binds the range; the transition +/// prepays, at Drive's estimate without reading state, the history index entries the +/// settlement rewrites. The settlement (`settle_random_token_amounts` 0, +/// `DRIVE_TOKEN_METHOD_VERSIONS_V3`) removes the reservation, pays out the drawn amount, +/// releases the rest and rewrites the history's amount, unbilled. The formats stay +/// inactive: `random_token_amount` is `None` in every serialization table, +/// `STATE_TRANSITION_SERIALIZATION_VERSIONS_V3` included, which this version shares with +/// 14, so a batch carrying one is refused at decode (`StateTransition::active_version_range`) +/// until block execution settles them from the beacon Tenderdash delivers. Activating them +/// selects a new serialization table for this version and leaves V3 as 14 has it. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, - drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13), and draw_random_document_properties Some(0) draws the sys.random properties of a block's documents and prices what their create prepays, which nothing calls while the function is inactive (note 18); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17) + drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13), and draw_random_document_properties Some(0) draws the sys.random properties of a block's documents and prices what their create prepays, which nothing calls while the function is inactive (note 18); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); token method versions v3: settle_random_token_amounts Some(0) settles a block's random token transfers and mints and prices what they prepay, which nothing calls while their formats are inactive (note 19) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: the yes/no poll's tally, record, clean-up and finish hook are Some(0) (note 10); verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock; consensus_params_update 2 installs the full evidence policy (note 16) From 6542187e97edf20fb417765eb7b2835b480cc617 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 08:03:15 +0700 Subject: [PATCH 5/8] test(platform): drive random token tests through the converters and the JS bindings - The settlement tests now reserve through the real conversion of a random transfer or mint action (into_high_level_batch_drive_operations), with history kept and not, and a test pins the operations: the maximum reserved, no fixed transfer or mint, and the history holding the minimum under the range-bound history operation. - wasm-dpp2: randomMinimumAmount on the transfer and mint wrappers, from construction, through the getter and setter (null and undefined clear it, a value above Number.MAX_SAFE_INTEGER stays exact, one outside u64 throws and keeps the previous value), and through BatchTransition bytes. Co-Authored-By: Claude Opus 5.5 --- .../settle_random_token_amounts/v0/mod.rs | 209 ++++++++++++++---- .../action_convert_to_operations/mod.rs | 2 +- .../tests/unit/TokensTransitions.spec.ts | 104 +++++++++ 3 files changed, 273 insertions(+), 42 deletions(-) diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs index 1c35febca56..e3b50e0ab57 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs @@ -332,7 +332,26 @@ mod tests { use crate::drive::tokens::settle_random_token_amounts::random_token_reservation_id; use crate::util::batch::drive_op_batch::TokenOperationType; use crate::util::batch::DriveOperation; + use crate::drive::contract::DataContractFetchInfo; + use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; + use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{ + TokenBaseTransitionAction, TokenBaseTransitionActionV0, + }; + use crate::state_transition_action::batch::batched_transition::token_transition::token_mint_transition_action::{ + TokenMintTransitionAction, TokenMintTransitionActionV0, + }; + use crate::state_transition_action::batch::batched_transition::token_transition::token_transfer_transition_action::{ + TokenTransferTransitionAction, TokenTransferTransitionActionV0, + }; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_keeps_history_rules::v0::TokenKeepsHistoryRulesV0; + use dpp::data_contract::config::v0::DataContractConfigV0; + use dpp::data_contract::v1::DataContractV1; + use grovedb_costs::OperationCost; + use std::collections::BTreeMap; + use std::sync::Arc; use dpp::platform_value::Value; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; @@ -416,49 +435,98 @@ mod tests { }) } - /// Applies what a random transfer or mint gathers when the block executes it: the - /// reservation, and the history document holding the range's minimum when `with_history`. - fn reserve(drive: &Drive, request: &RandomTokenRequest, with_history: bool) { - let token_id = request.token_id; - let (reservation, event) = match request.kind { - RandomTokenRequestKind::Transfer { - sender_id, - recipient_id, - } => ( - TokenOperationType::TokenReserveRandomTransfer { - token_id, - sender_id, - recipient_id, - identity_contract_nonce: request.identity_contract_nonce, - maximum: request.maximum, - }, - TokenEvent::Transfer(recipient_id, None, None, None, request.minimum), - ), - RandomTokenRequestKind::Mint { recipient_id } => ( - TokenOperationType::TokenReserveRandomMint { - token_id, - owner_id: request.owner_id, + /// A contract whose token, at position 0, keeps every kind of history when + /// `with_history`, and none otherwise. + fn token_contract(with_history: bool) -> Arc { + let mut token = TokenConfigurationV0::default_most_restrictive(); + token.keeps_history = + TokenKeepsHistoryRulesV0::default_for_keeping_all_history(with_history).into(); + Arc::new(DataContractFetchInfo { + contract: DataContract::V1(DataContractV1 { + id: Identifier::from([4; 32]), + version: 0, + owner_id: Identifier::from(SENDER), + document_types: Default::default(), + config: DataContractConfigV0::default().into(), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([(0, TokenConfiguration::V0(token))]), + keywords: Vec::new(), + description: None, + }), + storage_flags: None, + cost: OperationCost::default(), + fee: None, + }) + } + + /// The operations the block's execution of the random transfer or mint `request` + /// describes gathers: its action, as its transformer builds it, converted + /// (`into_high_level_batch_drive_operations`), with its token keeping history when + /// `with_history`. + fn random_operations( + request: &RandomTokenRequest, + with_history: bool, + ) -> Vec> { + let base = TokenBaseTransitionAction::V0(TokenBaseTransitionActionV0 { + token_id: request.token_id, + identity_contract_nonce: request.identity_contract_nonce, + token_contract_position: 0, + data_contract: token_contract(with_history), + store_in_group: None, + perform_action: true, + }); + let epoch = BlockInfo::default().epoch; + let platform_version = PlatformVersion::latest(); + match request.kind { + RandomTokenRequestKind::Transfer { recipient_id, .. } => { + TokenTransferTransitionAction::V0(TokenTransferTransitionActionV0 { + base, + amount: request.maximum, recipient_id, - identity_contract_nonce: request.identity_contract_nonce, - maximum: request.maximum, - allow_first_mint: false, - }, - TokenEvent::Mint(request.minimum, recipient_id, None), - ), - }; - let mut operations = vec![DriveOperation::TokenOperation(reservation)]; - if with_history { - operations.push(DriveOperation::TokenOperation( - TokenOperationType::TokenRandomAmountHistory { - token_id, - owner_id: request.owner_id, - nonce: request.identity_contract_nonce, - event, - minimum: request.minimum, - maximum: request.maximum, - }, - )); + public_note: None, + shared_encrypted_note: None, + private_encrypted_note: None, + random_minimum_amount: Some(request.minimum), + }) + .into_high_level_batch_drive_operations( + &epoch, + request.owner_id, + platform_version, + ) + } + RandomTokenRequestKind::Mint { recipient_id } => { + TokenMintTransitionAction::V0(TokenMintTransitionActionV0 { + base, + mint_amount: request.maximum, + identity_balance_holder_id: recipient_id, + public_note: None, + random_minimum_amount: Some(request.minimum), + }) + .into_high_level_batch_drive_operations( + &epoch, + request.owner_id, + platform_version, + ) + } } + .expect("expected the random action to convert") + } + + /// Applies what the block's execution of `request` writes ([`random_operations`]): the + /// reservation, and the history document holding the range's minimum when `with_history`. + /// The identity contract nonce update is left out, as these tests keep no identities. + fn reserve(drive: &Drive, request: &RandomTokenRequest, with_history: bool) { + let operations = random_operations(request, with_history) + .into_iter() + .filter(|operation| matches!(operation, DriveOperation::TokenOperation(_))) + .collect(); drive .apply_drive_operations( operations, @@ -564,6 +632,65 @@ mod tests { .map(|amount: &Value| amount.to_integer::().expect("an integer amount")) } + #[test] + fn should_convert_a_random_transfer_or_mint_into_a_reservation_and_a_range_bound_history() { + for request in [transfer(), mint()] { + for with_history in [true, false] { + let operations = random_operations(&request, with_history); + let token_operations: Vec<&TokenOperationType> = operations + .iter() + .filter_map(|operation| match operation { + DriveOperation::TokenOperation(operation) => Some(operation), + _ => None, + }) + .collect(); + // The maximum is reserved, and no fixed amount moves + let reserved: Vec = token_operations + .iter() + .filter_map(|operation| match operation { + TokenOperationType::TokenReserveRandomTransfer { maximum, .. } + | TokenOperationType::TokenReserveRandomMint { maximum, .. } => { + Some(*maximum) + } + _ => None, + }) + .collect(); + assert_eq!(reserved, vec![MAXIMUM]); + assert!(!token_operations.iter().any(|operation| matches!( + operation, + TokenOperationType::TokenTransfer { .. } + | TokenOperationType::TokenMint { .. } + | TokenOperationType::TokenHistory { .. } + ))); + // The history, when kept, holds the minimum under the id bound to the range + let histories: Vec<(TokenAmount, TokenAmount, TokenAmount)> = token_operations + .iter() + .filter_map(|operation| match operation { + TokenOperationType::TokenRandomAmountHistory { + event, + minimum, + maximum, + .. + } => { + let amount = match event { + TokenEvent::Transfer(_, _, _, _, amount) + | TokenEvent::Mint(amount, _, _) => *amount, + other => panic!("expected a transfer or mint event, got {other:?}"), + }; + Some((amount, *minimum, *maximum)) + } + _ => None, + }) + .collect(); + if with_history { + assert_eq!(histories, vec![(MINIMUM, MINIMUM, MAXIMUM)]); + } else { + assert!(histories.is_empty()); + } + } + } + } + #[test] fn should_settle_a_random_transfer_as_a_transfer_of_the_drawn_amount() { let drive = setup(); diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs index 2355587538f..aad57fd4f8a 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs @@ -4,7 +4,7 @@ //! mod address_funds; -mod batch; +pub(crate) mod batch; mod contract; mod identity; mod shielded; diff --git a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts index 2751f3b4d8b..3342f490c0a 100644 --- a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts @@ -782,4 +782,108 @@ describe('TokenTransitions', () => { }); }); }); + + describe('randomMinimumAmount', () => { + const amount = BigInt(500); + const cases = [ + { + name: 'TokenTransferTransition', + create: (randomMinimumAmount?: bigint) => new wasm.TokenTransferTransition({ + base: baseTransition, + recipientId: ownerId, + amount, + ...(randomMinimumAmount === undefined ? {} : { randomMinimumAmount }), + }), + }, + { + name: 'TokenMintTransition', + create: (randomMinimumAmount?: bigint) => new wasm.TokenMintTransition({ + base: baseTransition, + issuedToIdentityId: ownerId, + amount, + ...(randomMinimumAmount === undefined ? {} : { randomMinimumAmount }), + }), + }, + ]; + + cases.forEach(({ name, create }) => { + describe(name, () => { + it('should be undefined for a fixed amount', () => { + expect(create().randomMinimumAmount).to.equal(undefined); + }); + + it('should return the minimum it was created with', () => { + expect(create(BigInt(10)).randomMinimumAmount).to.equal(BigInt(10)); + }); + + it('should set the minimum and clear it with null or undefined', () => { + const transition = create(); + + transition.randomMinimumAmount = BigInt(10); + expect(transition.randomMinimumAmount).to.equal(BigInt(10)); + + transition.randomMinimumAmount = null; + expect(transition.randomMinimumAmount).to.equal(undefined); + + transition.randomMinimumAmount = BigInt(20); + transition.randomMinimumAmount = undefined; + expect(transition.randomMinimumAmount).to.equal(undefined); + }); + + it('should keep a minimum above Number.MAX_SAFE_INTEGER exactly', () => { + const minimum = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(2); + const transition = create(); + + transition.randomMinimumAmount = minimum; + + expect(transition.randomMinimumAmount).to.equal(minimum); + }); + + it('should refuse a minimum outside u64 and keep the previous one', () => { + const transition = create(BigInt(10)); + + expect(() => { + transition.randomMinimumAmount = BigInt(2) ** BigInt(64); + }).to.throw(); + expect(() => { + transition.randomMinimumAmount = BigInt(-1); + }).to.throw(); + + expect(transition.randomMinimumAmount).to.equal(BigInt(10)); + }); + + it('should keep the minimum and the amount through BatchTransition bytes', () => { + const batchOf = (transition: ReturnType) => wasm.BatchTransition + .fromBatchedTransitions( + [new wasm.BatchedTransition(new wasm.TokenTransition(transition))], + ownerId, + 1, + ); + const restore = (transition: ReturnType) => { + const tokenTransition = wasm.BatchTransition + .fromBytes(batchOf(transition).toBytes()) + .transitions[0].toTransition(); + if (!(tokenTransition instanceof wasm.TokenTransition)) { + throw new Error('expected a token transition'); + } + const restored = tokenTransition.transition; + if (!(restored instanceof wasm.TokenTransferTransition) + && !(restored instanceof wasm.TokenMintTransition)) { + throw new Error('expected a token transfer or mint'); + } + expect(restored.constructor.name).to.equal(name); + return restored; + }; + + const random = restore(create(BigInt(10))); + expect(random.randomMinimumAmount).to.equal(BigInt(10)); + expect(random.amount).to.equal(amount); + + const fixed = restore(create()); + expect(fixed.randomMinimumAmount).to.equal(undefined); + expect(fixed.amount).to.equal(amount); + }); + }); + }); + }); }); From 7b501aedbceb9132f8cf1cfbded64af6c2553edd Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 12:58:32 +0700 Subject: [PATCH 6/8] fix(drive): prepay a random token amount's settlement with its history write The transfer and mint transformers added the prepayment of the history index entries the settlement may create to the action's fee. A transfer or mint that state validation refused afterwards kept that fee, because the batch keeps the transformer's fee for the nonce bump it substitutes. The TokenRandomAmountHistory operation now charges it, as a precalculated fee, with the history document it writes, so estimation and application both include it once, and a transition refused at any stage pays nothing for it. Also lists the Rust API changes of random token amounts in the changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++ .../tokens/settle_random_token_amounts/mod.rs | 14 +-- .../settle_random_token_amounts/v0/mod.rs | 85 +++++++++++++++ .../v0/transformer.rs | 102 +++++------------- .../v0/transformer.rs | 80 ++++---------- .../src/util/batch/drive_op_batch/token.rs | 21 +++- .../drive_token_method_versions/mod.rs | 6 +- 7 files changed, 184 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 548de9ed6eb..508e42459b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,26 @@ version 15). This is a source change for Rust code whichever protocol version it runs: a `GeneratedFrom` literal sets `range: None` for every function but a random one, and an exhaustive match on `SystemFunction` needs a `Random` arm. +- **dpp, drive, rs-sdk (Rust API):** random token transfer and mint amounts (protocol + version 15) change these items for Rust code whichever protocol version it runs: + - `TokenEvent::build_historical_document_owned` and + `Drive::add_token_transaction_history_operations` take a `random_range` argument before + `block_info`. Pass `None` for the history of a fixed amount, which keeps its document id. + - `TokenTransferTransition` and `TokenMintTransition` gain a `V1` variant + (`TokenTransferTransitionV1`, `TokenMintTransitionV1`); `TokenOperationType` gains + `TokenReserveRandomTransfer`, `TokenReserveRandomMint` and `TokenRandomAmountHistory`; and + `BasicError` gains `RandomTokenMintNotAllowedError`. An exhaustive match on any of them + needs the new arms. + - `StateTransitionCreationOptions`, the drive actions `TokenTransferTransitionActionV0` and + `TokenMintTransitionActionV0`, and the rs-sdk `TokenTransferTransitionBuilder` and + `TokenMintTransitionBuilder` gain a public `random_minimum_amount` field. A struct literal + sets it to `None` for a fixed amount, or ends with `..Default::default()` where the type + has a default. + - The traits `TokenTransferTransitionV0Methods`, `TokenMintTransitionV0Methods`, + `TokenTransferTransitionActionAccessorsV0` and `TokenMintTransitionActionAccessorsV0` gain + a required `random_minimum_amount` method, and `TokenTransitionV0Methods` a required + `random_amount_range` method. An implementation outside these crates returns `None` for a + fixed amount. - **drive (Rust API):** Drive's cost estimators write their layer descriptions into `drive::util::estimated_layers::EstimatedLayers` instead of `HashMap`, in `DriveLowLevelOperationConverter` diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs index 1cf475a80bf..a736b974529 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs @@ -159,12 +159,14 @@ impl Drive { /// ([`Self::settle_random_token_amounts`]). The transition writes the document holding the /// range's minimum, and the settlement moves its entry in each index of the document's type /// holding `amount` (`byAmount`, under the token) to the drawn amount. The branch it moves - /// into may not exist yet: the settlement creates it, and is not billed. So the transition - /// pays for those entries once more, at what Drive's fee estimation gives for inserting the - /// document's entries into those indexes without reading state: the average layer - /// information it prices a dry run with, as if the branches had to be created (the average - /// case, whatever the draw finds). Selected by the settlement's method version, whose cost - /// model it is part of. + /// into may not exist yet: the settlement creates it, and is not billed. So the write of the + /// history document (`TokenOperationType::TokenRandomAmountHistory`) pays for those entries + /// once more, at what Drive's fee estimation gives for inserting the document's entries + /// into those indexes without reading state: the average layer information it prices a dry + /// run with, as if the branches had to be created (the average case, whatever the draw + /// finds). A transfer or mint refused before its execution writes no history document, so + /// it pays nothing for them. Selected by the settlement's method version, whose cost model + /// it is part of. /// /// # Parameters /// - `token_id`: the token. diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs index e3b50e0ab57..8045ead7d8f 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs @@ -632,6 +632,91 @@ mod tests { .map(|amount: &Value| amount.to_integer::().expect("an integer amount")) } + /// The history write of `request`'s transition, as its conversion gathers it + /// ([`random_operations`]), its token keeping history. + fn random_history_write(request: &RandomTokenRequest) -> TokenOperationType { + random_operations(request, true) + .into_iter() + .find_map(|operation| match operation { + DriveOperation::TokenOperation( + history @ TokenOperationType::TokenRandomAmountHistory { .. }, + ) => Some(history), + _ => None, + }) + .expect("expected a range-bound history write") + } + + /// What applying `history` on a fresh drive bills, or what estimating it does when not + /// `apply`. + fn history_write_fee(history: TokenOperationType, apply: bool) -> FeeResult { + setup() + .apply_drive_operations( + vec![DriveOperation::TokenOperation(history)], + apply, + &BlockInfo::default(), + None, + PlatformVersion::latest(), + None, + ) + .expect("expected the history write to apply") + } + + #[test] + fn should_prepay_the_settlement_once_with_the_history_write() { + let platform_version = PlatformVersion::latest(); + let drive = setup(); + for request in [transfer(), mint()] { + let random = random_history_write(&request); + let TokenOperationType::TokenRandomAmountHistory { + token_id, + owner_id, + nonce, + event, + minimum, + maximum, + } = random.clone() + else { + unreachable!("a range-bound history write"); + }; + let prepayment = drive + .prepay_random_token_history_entries( + token_id, + owner_id, + nonce, + event.clone(), + (minimum, maximum), + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected the prepayment to be priced"); + assert!( + prepayment.storage_fee > 0 && prepayment.processing_fee > 0, + "expected the settlement's entries to cost something: {prepayment:?}" + ); + // The same event written as the history of a fixed amount: a document of the same + // size under an id of the same size, with no settlement to prepay + let fixed = TokenOperationType::TokenHistory { + token_id, + owner_id, + nonce, + event, + }; + for apply in [false, true] { + let random_fee = history_write_fee(random.clone(), apply); + let fixed_fee = history_write_fee(fixed.clone(), apply); + assert_eq!( + ( + random_fee.storage_fee - fixed_fee.storage_fee, + random_fee.processing_fee - fixed_fee.processing_fee + ), + (prepayment.storage_fee, prepayment.processing_fee), + "expected the history write to prepay once (applied: {apply})" + ); + } + } + } + #[test] fn should_convert_a_random_transfer_or_mint_into_a_reservation_and_a_range_bound_history() { for request in [transfer(), mint()] { diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs index 38f27fd536b..e8b32401e33 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_mint_transition_action/v0/transformer.rs @@ -16,8 +16,6 @@ use crate::state_transition_action::batch::batched_transition::token_transition: use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dpp::fee::fee_result::FeeResult; -use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Getters; -use dpp::tokens::token_event::TokenEvent; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use platform_version::version::PlatformVersion; use crate::drive::Drive; @@ -384,9 +382,10 @@ impl TokenMintTransitionActionV0 { /// Converts a `TokenMintTransitionV1` into a `TokenMintTransitionActionV0`: the version 0 /// conversion of its version 0 fields, the mint action it yields carrying the random - /// minimum amount. A random mint of a token keeping minting history also pays in advance - /// for the history index entries the settlement of its amount may create - /// (`Drive::prepay_random_token_history_entries`). + /// minimum amount. It bills only what the version 0 conversion does: the history index + /// entries the settlement of a random amount may create are prepaid by the write of the + /// history document (`TokenOperationType::TokenRandomAmountHistory`), which a mint refused + /// later never reaches. /// /// # Arguments /// @@ -396,8 +395,7 @@ impl TokenMintTransitionActionV0 { /// # Returns /// /// * `Result<(ConsensusValidationResult, FeeResult), Error>` - The - /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made, - /// and of the prepaid history index entries. + /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made. #[allow(clippy::too_many_arguments)] pub fn try_from_token_mint_transition_v1_with_contract_lookup( drive: &Drive, @@ -417,58 +415,23 @@ impl TokenMintTransitionActionV0 { Error, > { let (v0, random_minimum_amount) = value.into_v0_and_random_minimum_amount(); - let (mut result, mut fee_result) = - Self::try_from_token_mint_transition_with_contract_lookup( - drive, - owner_id, - v0, - approximate_without_state_for_costs, - transaction, - block_info, - user_fee_increase, - get_data_contract, - platform_version, - )?; - // A refused mint holds a nonce bump instead, which has no random amount and writes no - // history + let (mut result, fee_result) = Self::try_from_token_mint_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + )?; + // A refused mint holds a nonce bump instead, which has no random amount if let Some(BatchedTransitionAction::TokenAction(TokenTransitionAction::MintAction( TokenMintTransitionAction::V0(mint), ))) = result.data.as_mut() { mint.random_minimum_amount = random_minimum_amount; - - // The execution writes the mint's history document holding the range's minimum, - // and the settlement moves its `byAmount` index entry to the drawn amount, which may - // create a branch the settlement does not bill. So the mint prepays that entry at - // the average case: what Drive's fee estimation gives for inserting the document's - // entries into the indexes holding the amount, without reading state. The execution - // writes the history only for a mint it performs, which a random mint always is (it - // is no group action). - if let Some(minimum) = random_minimum_amount { - if mint.base.perform_action() - && mint - .base - .token_configuration()? - .keeps_history() - .keeps_minting_history() - { - let history_event = TokenEvent::Mint( - minimum, - mint.identity_balance_holder_id, - mint.public_note.clone(), - ); - fee_result.checked_add_assign(drive.prepay_random_token_history_entries( - mint.base.token_id(), - owner_id, - mint.base.identity_contract_nonce(), - history_event, - (minimum, mint.mint_amount), - block_info, - transaction, - platform_version, - )?)?; - } - } } Ok((result, fee_result)) } @@ -484,8 +447,7 @@ impl TokenMintTransitionActionV0 { /// # Returns /// /// * `Result<(ConsensusValidationResult, FeeResult), Error>` - The - /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made, - /// and of the prepaid history index entries. + /// mint action, or the nonce bump of a refused mint, and the fees of the lookups it made. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_mint_transition_v1_with_contract_lookup( drive: &Drive, @@ -521,7 +483,7 @@ impl TokenMintTransitionActionV0 { } #[cfg(test)] -mod random_amount_prepay_tests { +mod random_amount_tests { use super::*; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; @@ -635,21 +597,15 @@ mod random_amount_prepay_tests { } #[test] - fn should_prepay_the_history_entry_of_a_random_mint() { - let random = mint_fee(true, true); - let fixed = mint_fee(true, false); - assert!( - random.storage_fee > fixed.storage_fee, - "expected the random mint to prepay storage: {random:?} vs {fixed:?}" - ); - assert!( - random.processing_fee > fixed.processing_fee, - "expected the random mint to prepay processing: {random:?} vs {fixed:?}" - ); - } - - #[test] - fn should_prepay_nothing_for_a_random_mint_of_a_token_keeping_no_minting_history() { - assert_eq!(mint_fee(false, true), mint_fee(false, false)); + fn should_bill_a_random_mint_action_what_a_fixed_one_costs() { + // The history write prepays the entries the settlement may create, so a mint its + // state validation refuses after this, whose action fee is still billed, prepays none + for keeps_history in [true, false] { + assert_eq!( + mint_fee(keeps_history, true), + mint_fee(keeps_history, false), + "keeps history: {keeps_history}" + ); + } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs index b7d8566d70b..7c703bca0a6 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transfer_transition_action/v0/transformer.rs @@ -1,5 +1,3 @@ -use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; -use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Getters; use dpp::identifier::Identifier; use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; use dpp::state_transition::batch_transition::token_transfer_transition::TokenTransferTransitionV1; @@ -8,12 +6,11 @@ use grovedb::TransactionArg; use std::sync::Arc; use dpp::block::block_info::BlockInfo; use dpp::fee::fee_result::FeeResult; -use dpp::tokens::token_event::TokenEvent; use platform_version::version::PlatformVersion; use crate::drive::contract::DataContractFetchInfo; use crate::drive::Drive; use crate::error::Error; -use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; use crate::state_transition_action::batch::batched_transition::token_transition::token_transfer_transition_action::TokenTransferTransitionActionV0; impl TokenTransferTransitionActionV0 { @@ -187,10 +184,11 @@ impl TokenTransferTransitionActionV0 { } /// Converts a `TokenTransferTransitionV1` into a `TokenTransferTransitionActionV0`: the - /// version 0 conversion of its version 0 fields, carrying its random minimum amount. A - /// random transfer of a token keeping transfer history also pays in advance for the history - /// index entries the settlement of its amount may create - /// (`Drive::prepay_random_token_history_entries`). + /// version 0 conversion of its version 0 fields, carrying its random minimum amount. It + /// bills only what the version 0 conversion does: the history index entries the settlement + /// of a random amount may create are prepaid by the write of the history document + /// (`TokenOperationType::TokenRandomAmountHistory`), which a transfer refused later never + /// reaches. /// /// # Arguments /// @@ -200,7 +198,7 @@ impl TokenTransferTransitionActionV0 { /// # Returns /// /// * `Result<(TokenTransferTransitionActionV0, FeeResult), Error>` - The action and the fees - /// of the lookups it made, and of the prepaid history index entries. + /// of the lookups it made. #[allow(clippy::too_many_arguments)] pub fn try_from_token_transfer_transition_v1_with_contract_lookup( drive: &Drive, @@ -213,7 +211,7 @@ impl TokenTransferTransitionActionV0 { platform_version: &PlatformVersion, ) -> Result<(Self, FeeResult), Error> { let (v0, random_minimum_amount) = value.into_v0_and_random_minimum_amount(); - let (mut action, mut fee_result) = + let (mut action, fee_result) = Self::try_from_token_transfer_transition_with_contract_lookup( drive, owner_id, @@ -225,38 +223,6 @@ impl TokenTransferTransitionActionV0 { platform_version, )?; action.random_minimum_amount = random_minimum_amount; - - // The execution writes the transfer's history document holding the range's minimum, - // and the settlement moves its `byAmount` index entry to the drawn amount, which may - // create a branch the settlement does not bill. So the transfer prepays that entry at - // the average case: what Drive's fee estimation gives for inserting the document's - // entries into the indexes holding the amount, without reading state. - if let Some(minimum) = random_minimum_amount { - if action - .base - .token_configuration()? - .keeps_history() - .keeps_transfer_history() - { - let history_event = TokenEvent::Transfer( - action.recipient_id, - action.public_note.clone(), - action.shared_encrypted_note.clone(), - action.private_encrypted_note.clone(), - minimum, - ); - fee_result.checked_add_assign(drive.prepay_random_token_history_entries( - action.base.token_id(), - owner_id, - action.base.identity_contract_nonce(), - history_event, - (minimum, action.amount), - block_info, - transaction, - platform_version, - )?)?; - } - } Ok((action, fee_result)) } @@ -272,7 +238,7 @@ impl TokenTransferTransitionActionV0 { /// # Returns /// /// * `Result<(TokenTransferTransitionActionV0, FeeResult), Error>` - The action and the fees - /// of the lookups it made, and of the prepaid history index entries. + /// of the lookups it made. #[allow(clippy::too_many_arguments)] pub fn try_from_borrowed_token_transfer_transition_v1_with_contract_lookup( drive: &Drive, @@ -626,7 +592,7 @@ mod tests { } #[cfg(test)] -mod random_amount_prepay_tests { +mod random_amount_tests { use super::*; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -724,21 +690,15 @@ mod random_amount_prepay_tests { } #[test] - fn should_prepay_the_history_entry_of_a_random_transfer() { - let random = transfer_fee(true, true); - let fixed = transfer_fee(true, false); - assert!( - random.storage_fee > fixed.storage_fee, - "expected the random transfer to prepay storage: {random:?} vs {fixed:?}" - ); - assert!( - random.processing_fee > fixed.processing_fee, - "expected the random transfer to prepay processing: {random:?} vs {fixed:?}" - ); - } - - #[test] - fn should_prepay_nothing_for_a_random_transfer_of_a_token_keeping_no_transfer_history() { - assert_eq!(transfer_fee(false, true), transfer_fee(false, false)); + fn should_bill_a_random_transfer_action_what_a_fixed_one_costs() { + // The history write prepays the entries the settlement may create, so a transfer its + // state validation refuses after this, whose action fee is still billed, prepays none + for keeps_history in [true, false] { + assert_eq!( + transfer_fee(keeps_history, true), + transfer_fee(keeps_history, false), + "keeps history: {keeps_history}" + ); + } } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index d6704272632..8c1461b8c52 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -231,7 +231,9 @@ pub enum TokenOperationType { }, /// Adds the historical document of a random token transfer or mint (protocol version 15), /// whose id also hashes the range the amount is drawn from - /// (`token_history_document_id`), so that a proof of the document binds the range. + /// (`token_history_document_id`), so that a proof of the document binds the range, and + /// prepays the index entries the settlement of the amount may create + /// (`Drive::prepay_random_token_history_entries`). TokenRandomAmountHistory { /// The token id token_id: Identifier, @@ -361,7 +363,21 @@ impl DriveLowLevelOperationConverter for TokenOperationType { minimum, maximum, } => { - let batch_operations = drive.add_token_transaction_history_operations( + // The settlement moves the document's `byAmount` index entry to the drawn + // amount, which may create a branch it does not bill, so the write prepays that + // entry: with the document, so a transfer or mint refused before its execution + // writes no history and prepays nothing + let prepayment = drive.prepay_random_token_history_entries( + token_id, + owner_id, + nonce, + event.clone(), + (minimum, maximum), + block_info, + transaction, + platform_version, + )?; + let mut batch_operations = drive.add_token_transaction_history_operations( token_id, owner_id, nonce, @@ -372,6 +388,7 @@ impl DriveLowLevelOperationConverter for TokenOperationType { transaction, platform_version, )?; + batch_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(prepayment)); Ok(batch_operations) } TokenOperationType::TokenFreeze { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs index b4e13a29029..af82e8a81eb 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs @@ -17,9 +17,9 @@ pub struct DriveTokenMethodVersions { /// `settle_random_token_amounts`: pays out the random token transfers and mints of a block /// from the block's random beacon, once every transition of the block is accepted, and /// `prepay_random_token_history_entries`, the same generation's price of the history index - /// entries the settlement may create, which the transition pays. `Some(0)` from - /// `DRIVE_TOKEN_METHOD_VERSIONS_V3` (protocol version 15), `None` in every earlier table, - /// whose protocol versions decode no random token amount. + /// entries the settlement may create, which the transition's history write pays. `Some(0)` + /// from `DRIVE_TOKEN_METHOD_VERSIONS_V3` (protocol version 15), `None` in every earlier + /// table, whose protocol versions decode no random token amount. pub settle_random_token_amounts: OptionalFeatureVersion, } From 33fdf44a81309b8962ab1c20eebc4b32ce27e4b0 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 17:48:54 +0700 Subject: [PATCH 7/8] fix(drive): refuse a batch reserving two random amounts of one token A random transfer or mint reservation creates its token's escrow tree when the state before the batch holds none. Two reservations of one token in a direct Drive batch both queued that creation, which GroveDB's consistency check refused as an insertion order error. refuse_repeated_token_balance_writes now treats the token's escrow tree as a key each reservation writes, so such a batch is refused before it writes anything, as other repeated token writes are. No state transition reaches it while a documents batch carries one transition; the cap's documentation lists it among what raising the cap must handle. Co-Authored-By: Claude Opus 5.5 --- .../settle_random_token_amounts/v0/mod.rs | 66 ++++++++++++++++++ .../src/util/batch/drive_op_batch/mod.rs | 68 ++++++++++++++++--- .../src/util/batch/drive_op_batch/token.rs | 4 +- .../src/version/system_limits/mod.rs | 8 +++ 4 files changed, 137 insertions(+), 9 deletions(-) diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs index 8045ead7d8f..815083bf0ff 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs @@ -880,6 +880,72 @@ mod tests { assert_tokens_balanced(&drive); } + #[test] + fn should_refuse_a_batch_reserving_two_random_amounts_of_one_token() { + // Two random transfers of the token with disjoint balances, in one batch: each would + // create the token's escrow tree, which it does not have yet, and GroveDB's consistency + // check refuses the creation queued twice, so the batch is refused before it writes + let drive = setup(); + assert!(drive.config.batching_consistency_verification); + let other_sender = [9; 32]; + drive + .token_mint( + TOKEN, + other_sender, + MAXIMUM, + false, + false, + &BlockInfo::default(), + true, + None, + PlatformVersion::latest(), + ) + .expect("expected to mint the other sender's balance"); + let first = transfer(); + let second = RandomTokenRequest { + owner_id: Identifier::from(other_sender), + kind: RandomTokenRequestKind::Transfer { + sender_id: Identifier::from(other_sender), + recipient_id: Identifier::from([10; 32]), + }, + ..transfer() + }; + assert_eq!(reserved(&drive, &first), None); + + let operations = random_operations(&first, false) + .into_iter() + .chain(random_operations(&second, false)) + .filter(|operation| matches!(operation, DriveOperation::TokenOperation(_))) + .collect(); + let result = drive.apply_drive_operations( + operations, + true, + &BlockInfo::default(), + None, + PlatformVersion::latest(), + None, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "{result:?}" + ); + assert_eq!(reserved(&drive, &first), None); + assert_eq!(reserved(&drive, &second), None); + assert_eq!(balance(&drive, SENDER), Some(BASE_SUPPLY)); + assert_eq!(balance(&drive, other_sender), Some(MAXIMUM)); + assert_tokens_balanced(&drive); + + // Batch by batch, the first creates the escrow tree and the second finds it + reserve(&drive, &first, false); + reserve(&drive, &second, false); + assert_eq!(reserved(&drive, &first), Some(MAXIMUM)); + assert_eq!(reserved(&drive, &second), Some(MAXIMUM)); + assert_tokens_balanced(&drive); + } + #[test] fn should_settle_two_requests_crediting_one_balance() { let drive = setup(); diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index 7732bf030d5..99cb0399314 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -538,21 +538,25 @@ impl DriveOperation<'_> { } /// Refuses a batch whose token operations write one identity's balance of a token, one - /// token's total supply, or the total one token's shielded pool holds, more than once. + /// token's total supply, or the total one token's shielded pool holds, more than once, or + /// reserve two random amounts of one token. /// /// These writes compute the new value from the one committed before the batch too, but a /// transfer writes two balances, a mint or a burn a balance and the supply, and a shield /// or an unshield a balance and the pool total, so they cannot be merged into operations - /// of their own kinds. No state transition makes two of them on one key: a batch carries - /// one transition, and each writes a key at most once. A batch that did would lose all but - /// the last write, so it is refused instead. + /// of their own kinds. A random reservation (protocol version 15) creates its token's + /// escrow tree when the state before the batch holds none, so a second one of the token + /// would queue the tree's creation again, which GroveDB's consistency check refuses. No + /// state transition makes two of them on one key: a batch carries one transition, and each + /// writes a key at most once. A batch that did would lose all but the last write, or fail + /// as it applies, so it is refused instead. pub fn refuse_repeated_token_balance_writes(operations: &[Self]) -> Result<(), Error> { let mut written = BTreeSet::new(); for key in operations.iter().flat_map(token_balance_writes) { if !written.insert(key) { return Err(Error::Drive(DriveError::CorruptedCodeExecution( "a batch writes one token balance, token supply or token shielded pool \ - total more than once", + total more than once, or reserves two random amounts of one token", ))); } } @@ -570,12 +574,14 @@ enum BalanceKey { } /// A key a token operation writes: an identity's balance of a token, a token's total supply, -/// or the total a token's shielded pool holds +/// the total a token's shielded pool holds, or the escrow tree of a token, which a random +/// reservation creates when the state before the batch holds none #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum TokenBalanceKey { Holder(Identifier, Identifier), Supply(Identifier), PoolTotal(Identifier), + RandomReservationEscrow(Identifier), } /// The keys `operation` writes if it is a token operation that moves tokens @@ -636,8 +642,9 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { ], // Reserving a random transfer moves the most it moves from the sender into the token's // escrow, and reserving a random mint raises the total supply by it and holds it in - // the escrow; both write the recipient's balance unchanged. The settlement writes the - // rest once the block's beacon is known. + // the escrow; both write the recipient's balance unchanged, and create the token's + // escrow tree when it has none. The settlement writes the rest once the block's beacon + // is known. TokenOperationType::TokenReserveRandomTransfer { token_id, sender_id, @@ -646,6 +653,7 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { } => vec![ TokenBalanceKey::Holder(*token_id, *sender_id), TokenBalanceKey::Holder(*token_id, *recipient_id), + TokenBalanceKey::RandomReservationEscrow(*token_id), ], TokenOperationType::TokenReserveRandomMint { token_id, @@ -654,6 +662,7 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { } => vec![ TokenBalanceKey::Holder(*token_id, *recipient_id), TokenBalanceKey::Supply(*token_id), + TokenBalanceKey::RandomReservationEscrow(*token_id), ], // Minting into the pool raises the total supply and the pool total by the same amount // and burning from it lowers both; neither touches an identity's balance. @@ -1854,6 +1863,49 @@ mod tests { ); } + fn reserve_random_transfer(token_position: u8, from: u8, to: u8) -> DriveOperation<'static> { + DriveOperation::TokenOperation(TokenOperationType::TokenReserveRandomTransfer { + token_id: token(token_position), + sender_id: Identifier::new([from; 32]), + recipient_id: Identifier::new([to; 32]), + identity_contract_nonce: 1, + maximum: 5, + }) + } + + fn reserve_random_mint(token_position: u8, to: u8) -> DriveOperation<'static> { + DriveOperation::TokenOperation(TokenOperationType::TokenReserveRandomMint { + token_id: token(token_position), + owner_id: Identifier::new([9; 32]), + recipient_id: Identifier::new([to; 32]), + identity_contract_nonce: 1, + maximum: 5, + allow_first_mint: false, + }) + } + + #[test] + fn should_refuse_a_batch_that_reserves_two_random_amounts_of_one_token() { + // Both would create the token's escrow tree when it has none, though they write + // disjoint balances + assert!(DriveOperation::refuse_repeated_token_balance_writes(&[ + reserve_random_transfer(0, 1, 2), + reserve_random_transfer(0, 3, 4), + ]) + .is_err()); + assert!(DriveOperation::refuse_repeated_token_balance_writes(&[ + reserve_random_transfer(0, 1, 2), + reserve_random_mint(0, 4), + ]) + .is_err()); + // Reservations of two tokens each create their own token's escrow tree + assert!(DriveOperation::refuse_repeated_token_balance_writes(&[ + reserve_random_transfer(0, 1, 2), + reserve_random_mint(1, 4), + ]) + .is_ok()); + } + #[test] fn should_admit_token_writes_that_each_touch_their_own_keys() { assert!(DriveOperation::refuse_repeated_token_balance_writes(&[ diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index 8c1461b8c52..8c8d6e57152 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -646,7 +646,9 @@ impl DriveLowLevelOperationConverter for TokenOperationType { /// never sees it; a read during the block finds no listing under its key, which the domain of /// the reservation id keeps apart from every order id. The token conservation check counts the /// escrow, so it balances while the entry exists. A batch carries one transition, so no -/// operation queued before this one in the batch creates the token's escrow tree. +/// operation queued before this one in the batch creates the token's escrow tree, and +/// `DriveOperation::refuse_repeated_token_balance_writes` refuses a batch reserving two random +/// amounts of one token, whose second reservation would queue the tree's creation again. #[allow(clippy::too_many_arguments)] fn random_token_reservation_operations( drive: &Drive, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index b9d7f448f83..d4250df1baa 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -176,6 +176,14 @@ pub struct SystemLimits { /// internal error. Raising the cap still requires delta-based pool balance writes and a /// review of the other balance, nonce, counter and group dependencies above. /// + /// * A random token transfer or mint (protocol version 15) reserves its maximum in the + /// token's escrow, creating the token's escrow tree when the state before the batch holds + /// none. Two reservations of one token in a batch would each queue that creation, which + /// GroveDB's consistency check refuses, so `refuse_repeated_token_balance_writes` refuses + /// such a batch as an internal error. Raising the cap needs the reservations built against + /// the batch's earlier operations (`add_to_token_order_escrow_operations` takes them) in + /// place of that refusal. + /// /// * The batch minimum balance pre-check reserves the compute fee of every shielded pool /// bundle a batch carries, on top of `document_batch_sub_transition` per sub-transition. /// It refuses nobody who could have paid only because a bundle-carrying sub-transition's From 9db7ed42fb0741a840877e84a41f1454d6758714 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 9 Nov 2026 19:04:12 +0700 Subject: [PATCH 8/8] test(drive): reserve and settle random token amounts inside one uncommitted transaction Co-Authored-By: Claude Opus 5.5 --- .../settle_random_token_amounts/v0/mod.rs | 175 +++++++++++++++++- 1 file changed, 168 insertions(+), 7 deletions(-) diff --git a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs index 869a0b2d3a9..d7e580d214a 100644 --- a/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs @@ -523,6 +523,16 @@ mod tests { /// reservation, and the history document holding the range's minimum when `with_history`. /// The identity contract nonce update is left out, as these tests keep no identities. fn reserve(drive: &Drive, request: &RandomTokenRequest, with_history: bool) { + reserve_in(drive, request, with_history, None) + } + + /// [`reserve`], inside `transaction`. + fn reserve_in( + drive: &Drive, + request: &RandomTokenRequest, + with_history: bool, + transaction: TransactionArg, + ) { let operations = random_operations(request, with_history) .into_iter() .filter(|operation| matches!(operation, DriveOperation::TokenOperation(_))) @@ -532,43 +542,80 @@ mod tests { operations, true, &BlockInfo::default(), - None, + transaction, PlatformVersion::latest(), ) .expect("expected the reservation to apply"); } fn settle(drive: &Drive, requests: &[RandomTokenRequest]) -> u32 { + settle_in(drive, requests, None) + } + + /// [`settle`], inside `transaction`. + fn settle_in( + drive: &Drive, + requests: &[RandomTokenRequest], + transaction: TransactionArg, + ) -> u32 { drive .settle_random_token_amounts( &BEACON, requests, &BlockInfo::default(), - None, + transaction, PlatformVersion::latest(), ) .expect("expected the settlement to run") } fn balance(drive: &Drive, identity_id: [u8; 32]) -> Option { + balance_in(drive, identity_id, None) + } + + /// [`balance`], inside `transaction`. + fn balance_in( + drive: &Drive, + identity_id: [u8; 32], + transaction: TransactionArg, + ) -> Option { drive - .fetch_identity_token_balance(TOKEN, identity_id, None, PlatformVersion::latest()) + .fetch_identity_token_balance( + TOKEN, + identity_id, + transaction, + PlatformVersion::latest(), + ) .expect("expected to fetch a token balance") } fn supply(drive: &Drive) -> Option { + supply_in(drive, None) + } + + /// [`supply`], inside `transaction`. + fn supply_in(drive: &Drive, transaction: TransactionArg) -> Option { drive - .fetch_token_total_supply(TOKEN, None, PlatformVersion::latest()) + .fetch_token_total_supply(TOKEN, transaction, PlatformVersion::latest()) .expect("expected to fetch the total supply") } /// What the token marketplace escrow holds under `request`'s reservation. fn reserved(drive: &Drive, request: &RandomTokenRequest) -> Option { + reserved_in(drive, request, None) + } + + /// [`reserved`], inside `transaction`. + fn reserved_in( + drive: &Drive, + request: &RandomTokenRequest, + transaction: TransactionArg, + ) -> Option { drive .fetch_token_order_escrow( TOKEN, request.reservation_id(), - None, + transaction, &mut vec![], PlatformVersion::latest(), ) @@ -577,8 +624,13 @@ mod tests { /// The identity balances, the shielded pools and the escrow add up to the total supplies. fn assert_tokens_balanced(drive: &Drive) { + assert_tokens_balanced_in(drive, None) + } + + /// [`assert_tokens_balanced`], inside `transaction`. + fn assert_tokens_balanced_in(drive: &Drive, transaction: TransactionArg) { let total = drive - .calculate_total_tokens_balance(None, PlatformVersion::latest()) + .calculate_total_tokens_balance(transaction, PlatformVersion::latest()) .expect("expected to sum the token balances"); assert!( total.ok().expect("expected the sums to compare"), @@ -591,6 +643,16 @@ mod tests { drive: &Drive, request: &RandomTokenRequest, document_type_name: &str, + ) -> Option { + history_amount_in(drive, request, document_type_name, None) + } + + /// [`history_amount`], inside `transaction`. + fn history_amount_in( + drive: &Drive, + request: &RandomTokenRequest, + document_type_name: &str, + transaction: TransactionArg, ) -> Option { let platform_version = PlatformVersion::latest(); let contract = drive @@ -615,7 +677,7 @@ mod tests { (&path).into(), document_id.as_slice(), DirectQueryType::StatefulDirectQuery, - None, + transaction, &mut vec![], &platform_version.drive, ) @@ -977,6 +1039,105 @@ mod tests { assert_tokens_balanced(&drive); } + #[test] + fn should_reserve_and_settle_overlapping_requests_inside_one_uncommitted_transaction() { + let drive = setup(); + let first = transfer(); + let second = RandomTokenRequest { + identity_contract_nonce: NONCE + 1, + ..transfer() + }; + let third = RandomTokenRequest { + identity_contract_nonce: NONCE + 2, + ..mint() + }; + let requests = [first.clone(), second.clone(), third.clone()]; + let first_drawn = draw_token_amount_v0(&BEACON, &first).expect("draws"); + let second_drawn = draw_token_amount_v0(&BEACON, &second).expect("draws"); + let third_drawn = draw_token_amount_v0(&BEACON, &third).expect("draws"); + + let assert_nothing_committed = || { + assert_eq!(balance(&drive, SENDER), Some(BASE_SUPPLY)); + assert_eq!(balance(&drive, RECIPIENT), None); + assert_eq!(supply(&drive), Some(BASE_SUPPLY)); + for request in &requests { + assert_eq!(reserved(&drive, request), None); + } + assert_eq!(history_amount(&drive, &first, "transfer"), None); + assert_eq!(history_amount(&drive, &second, "transfer"), None); + assert_eq!(history_amount(&drive, &third, "mint"), None); + assert_tokens_balanced(&drive); + }; + + let transaction = drive.grove.start_transaction(); + let block = Some(&transaction); + + for request in &requests { + reserve_in(&drive, request, true, block); + } + for request in &requests { + assert_eq!(reserved_in(&drive, request, block), Some(MAXIMUM)); + } + assert_eq!( + balance_in(&drive, SENDER, block), + Some(BASE_SUPPLY - 2 * MAXIMUM) + ); + assert_eq!(balance_in(&drive, RECIPIENT, block), Some(0)); + assert_eq!(supply_in(&drive, block), Some(BASE_SUPPLY + MAXIMUM)); + assert_eq!( + history_amount_in(&drive, &first, "transfer", block), + Some(MINIMUM) + ); + assert_eq!( + history_amount_in(&drive, &second, "transfer", block), + Some(MINIMUM) + ); + assert_eq!( + history_amount_in(&drive, &third, "mint", block), + Some(MINIMUM) + ); + assert_tokens_balanced_in(&drive, block); + assert_nothing_committed(); + + // Each settlement reads what the one before it left in the transaction + assert_eq!(settle_in(&drive, &requests, block), 3); + for request in &requests { + assert_eq!(reserved_in(&drive, request, block), None); + } + assert_eq!( + balance_in(&drive, SENDER, block), + Some(BASE_SUPPLY - first_drawn - second_drawn) + ); + assert_eq!( + balance_in(&drive, RECIPIENT, block), + Some(first_drawn + second_drawn + third_drawn) + ); + assert_eq!(supply_in(&drive, block), Some(BASE_SUPPLY + third_drawn)); + assert_eq!( + history_amount_in(&drive, &first, "transfer", block), + Some(first_drawn) + ); + assert_eq!( + history_amount_in(&drive, &second, "transfer", block), + Some(second_drawn) + ); + assert_eq!( + history_amount_in(&drive, &third, "mint", block), + Some(third_drawn) + ); + assert_tokens_balanced_in(&drive, block); + assert_nothing_committed(); + + drive + .grove + .rollback_transaction(&transaction) + .expect("expected to roll the block back"); + assert_nothing_committed(); + assert_eq!(balance_in(&drive, SENDER, block), Some(BASE_SUPPLY)); + assert_eq!(balance_in(&drive, RECIPIENT, block), None); + assert_eq!(supply_in(&drive, block), Some(BASE_SUPPLY)); + } + #[test] fn should_refuse_to_settle_a_request_nothing_reserved() { let drive = setup();