diff --git a/CHANGELOG.md b/CHANGELOG.md index 10576806219..d5400dd6f3a 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, dpp (Rust API):** `Drive::calculate_fee` is now a method and no longer takes `epochs_per_era` or `previous_fee_versions`: 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 b21ad1a639d..2ffca3cb116 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -136,7 +136,7 @@ use crate::consensus::basic::token::{ InvalidTokenDistributionTimeIntervalNotMinuteAlignedError, InvalidTokenDistributionTimeIntervalTooShortError, InvalidTokenIdError, InvalidTokenNoteTooBigError, InvalidTokenOncePerIdentityDistributionAmountError, - InvalidTokenPositionError, MissingDefaultLocalizationError, + InvalidTokenPositionError, MissingDefaultLocalizationError, RandomTokenMintNotAllowedError, TokenNoteOnlyAllowedWhenProposerError, TokenPricingScheduleEmptyError, TokenTransferToOurselfError, }; @@ -903,6 +903,11 @@ pub enum BasicError { #[error(transparent)] DuplicatePrivateDocumentEntryError(DuplicatePrivateDocumentEntryError), + + // 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 { @@ -1141,16 +1146,14 @@ 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 `PrivateDocumentCreateFromShieldedPool`'s entries (protocol version 15): the tail - // of the enum. + // A `PrivateDocumentCreateFromShieldedPool`'s entries (protocol version 15). assert_eq!( discriminant_of(BasicError::InvalidPrivateDocumentEntryCountError( InvalidPrivateDocumentEntryCountError::new(17, 16) @@ -1169,6 +1172,16 @@ mod tests { )), 213 ); + // 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() + ) + )), + 214 + ); } /// 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 bb3f52b8c71..161f405c6ba 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -195,6 +195,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 df55059a7b5..6a6506fc99a 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -23,7 +23,9 @@ use platform_version::version::feature_initial_protocol_versions::{ PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, 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", @@ -102,7 +104,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, }; @@ -1159,6 +1163,45 @@ 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, + }) +} + +/// 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 @@ -1248,6 +1291,16 @@ 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) 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 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 // earlier version rejects the batch without charging, exactly as they do. @@ -3646,6 +3699,137 @@ mod tests { assert_eq!(recovered, version_2); } + // 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_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; + 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)), + ))); + for random in [random_transfer, random_mint] { + assert_eq!(random.active_version_range(), NO_VERSION); + let bytes = + PlatformSerializable::serialize_to_bytes(&random).expect("serialize succeeds"); + 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-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..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 @@ -33,6 +33,12 @@ 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. Set on the + /// versioned [`TokenMintTransition`], which takes the format holding it. + fn random_minimum_amount(&self) -> Option; + /// Returns the `public_note` field of the `TokenMintTransitionV0`. fn public_note(&self) -> Option<&String>; @@ -62,6 +68,10 @@ impl TokenMintTransitionV0Methods for TokenMintTransitionV0 { self.amount = amount; } + fn random_minimum_amount(&self) -> Option { + None + } + 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..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 @@ -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,56 +15,96 @@ 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, } } } +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 { 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 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 +114,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 +134,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 +155,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..6ad746d79d5 --- /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,97 @@ +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 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..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 @@ -26,6 +26,14 @@ 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. + /// Set on the versioned + /// [`TokenTransferTransition`](crate::state_transition::batch_transition::TokenTransferTransition), + /// which takes the format holding it. + fn random_minimum_amount(&self) -> Option; + /// Returns the `recipient_owner_id` field of the `TokenTransferTransitionV0`. fn recipient_id(&self) -> Identifier; @@ -108,6 +116,10 @@ impl TokenTransferTransitionV0Methods for TokenTransferTransitionV0 { self.amount = amount; } + fn random_minimum_amount(&self) -> Option { + None + } + 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..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 @@ -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,50 +11,92 @@ 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, } } } +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 { 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 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 +104,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 +152,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 +165,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 +190,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 +215,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/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/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..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,10 +63,12 @@ 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")] +#[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")] +#[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 +127,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 +284,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 +298,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-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/document/draw_random_document_properties/v0/mod.rs b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs index 26b50f6341b..1db25126b3d 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 @@ -3,6 +3,7 @@ use crate::drive::document::expiration::pricing::{ document_remaining_lifetime_ms, document_ttl_pricing, }; 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; @@ -25,17 +26,10 @@ use dpp::document::{Document, DocumentV0Getters}; use dpp::fee::fee_result::FeeResult; use dpp::platform_value::btreemap_extensions::BTreeValueMapInsertionPathHelper; use dpp::prelude::Identifier; -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( @@ -61,15 +55,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. As the insert, the dry run first completes the document - /// with the values of the derived index properties (`with_derived_index_values`), which a - /// create's document does not carry yet, estimated: nothing is read, and the reads are - /// priced by their worst case. + /// 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( @@ -82,18 +71,61 @@ 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. As the insert, the dry run first completes the document with the values + /// of the derived index properties (`with_derived_index_values`), which a create's document + /// does not carry yet, estimated: nothing is read, and the reads are priced by their worst + /// case. 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(), @@ -290,49 +322,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 a1369ec2cbf..a7d94591967 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 @@ -228,12 +228,13 @@ impl Drive { /// document type, which dropping storage flags leaves as it is), so an update gathers the /// same operations as before at every protocol version, `references_follow_the_owner` /// being the generation's. `draw_random_document_properties` calls it to fill the - /// `sys.random` properties of a document of any type, which keeps its owner, with - /// `index_trees_as_inserted`: the index trees it creates then carry storage flags only - /// where an insert's do (`index_trees_carry_storage_flags`). The primary storage and the - /// references keep the document's flags either way, as an insert's do. + /// `sys.random` properties of a document of any type, and `settle_random_token_amounts` + /// to fill the drawn amount into a token history document, both keeping its owner and + /// with `index_trees_as_inserted`: the index trees it creates then carry storage flags + /// only where an insert's do (`index_trees_carry_storage_flags`). The primary storage and + /// the references keep the document's flags either way, as an insert's do. #[allow(clippy::too_many_arguments)] - pub(in crate::drive::document) fn update_stored_document_operations_v1( + pub(crate) fn update_stored_document_operations_v1( &self, document_and_contract_info: DocumentAndContractInfo, index_trees_as_inserted: bool, 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..ada367603f8 --- /dev/null +++ b/packages/rs-drive/src/drive/random_beacon/mod.rs @@ -0,0 +1,93 @@ +//! 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 (`RandomRange` is checked when it is built, +/// so its minimum is never above its maximum). `None` only for a beacon longer than a length +/// prefix holds, which no caller passes. +pub(crate) fn draw_from_beacon_v0( + beacon: &[u8], + request_key: &[u8], + range: RandomRange, +) -> Option { + 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::new(0, u64::MAX.into()).expect("a range of 2^64 values"); + 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() { + let beacon = [7u8; 96]; + for key in 0u8..32 { + let drawn = + draw_from_beacon_v0(&beacon, &[key], RandomRange::new(10, 12).expect("a range")) + .expect("draws"); + assert!((10..=12).contains(&drawn)); + } + assert_eq!( + draw_from_beacon_v0(&beacon, &[0], RandomRange::new(5, 5).expect("a range")), + Some(5) + ); + } +} 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/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..a736b974529 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/mod.rs @@ -0,0 +1,226 @@ +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::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; + +/// 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 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. + /// - `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], + })), + } + } + + /// 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 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. + /// - `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. + /// - `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, + /// 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, + random_range: (TokenAmount, TokenAmount), + 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, + random_range, + 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 new file mode 100644 index 00000000000..6f2356a5be4 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/settle_random_token_amounts/v0/mod.rs @@ -0,0 +1,1274 @@ +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, Index, RandomRange}; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +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; + +/// 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, + ) + } + + /// 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, + random_range: (TokenAmount, TokenAmount), + 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, + Some(random_range), + 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 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, + 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 = token_history_document_id( + &request.token_id, + &request.owner_id, + document_type_name, + request.identity_contract_nonce, + Some((request.minimum, request.maximum)), + ); + + 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, + }, + true, + false, + 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 range = RandomRange::new(i128::from(request.minimum), i128::from(request.maximum))?; + let drawn = draw_from_beacon_v0(beacon, &request_key, range)?; + 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::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}; + + 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), + }) + } + + /// 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, + 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) { + 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(_))) + .collect(); + drive + .apply_drive_operations( + operations, + true, + &BlockInfo::default(), + 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(), + 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, + 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, 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(), + transaction, + &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) { + 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(transaction, 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 { + 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 + .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 = token_history_document_id( + &request.token_id, + &request.owner_id, + 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); + let element = drive + .grove_get_raw_optional( + (&path).into(), + document_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &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")) + } + + /// 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(), + ) + .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()] { + 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(); + 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_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(), + ); + 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(); + 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_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(); + 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..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 @@ -81,25 +81,67 @@ 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() { - ops.push(TokenOperation(TokenOperationType::TokenHistory { - token_id: self.token_id(), - owner_id, - nonce: identity_contract_nonce, - event: TokenEvent::Mint( - self.mint_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 e84ed462f54..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 @@ -47,28 +47,66 @@ 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 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, - 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/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/action_convert_to_operations/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs index a5578043910..1ba7996c070 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/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/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 5d0c8d73e50..2f53558a9c6 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,14 @@ 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::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; @@ -188,6 +190,7 @@ impl TokenMintTransitionActionV0 { mint_amount: amount, identity_balance_holder_id, public_note: change_note.unwrap_or(public_note), + random_minimum_amount: None, } .into(), )) @@ -364,6 +367,7 @@ impl TokenMintTransitionActionV0 { mint_amount: *amount, identity_balance_holder_id, public_note: change_note.unwrap_or(public_note.clone()), + random_minimum_amount: None, } .into(), )) @@ -371,4 +375,233 @@ 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. 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 + /// + /// 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, + )?; + // 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; + } + Ok((result, fee_result)) + } + + /// Converts a borrowed `TokenMintTransitionV1` into a `TokenMintTransitionActionV0`: + /// [`Self::try_from_token_mint_transition_v1_with_contract_lookup`] of a copy. + /// + /// # 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, + > { + // 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, + ) + } +} + +#[cfg(test)] +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; + 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_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/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 2fcadf37d2d..6b276ce5cf7 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; @@ -89,6 +90,7 @@ impl TokenTransferTransitionActionV0 { public_note, shared_encrypted_note, private_encrypted_note, + random_minimum_amount: None, }, fee_result, )) @@ -171,10 +173,92 @@ 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. 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 + /// + /// 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`: + /// [`Self::try_from_token_transfer_transition_v1_with_contract_lookup`] of a copy. + /// + /// # 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> { + // 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, + ) + } } #[cfg(test)] @@ -232,6 +316,7 @@ mod tests { public_note: public_note.map(|s| s.to_string()), shared_encrypted_note: shared, private_encrypted_note: private_, + random_minimum_amount: None, } } @@ -501,3 +586,115 @@ mod tests { assert_eq!(ref_id, Identifier::new([21u8; 32])); } } + +#[cfg(test)] +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; + 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_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/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 abf7b5cb89c..89de3b8740d 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 @@ -574,21 +574,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", ))); } } @@ -606,12 +610,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 @@ -670,6 +676,30 @@ 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, 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, + recipient_id, + .. + } => vec![ + TokenBalanceKey::Holder(*token_id, *sender_id), + TokenBalanceKey::Holder(*token_id, *recipient_id), + TokenBalanceKey::RandomReservationEscrow(*token_id), + ], + TokenOperationType::TokenReserveRandomMint { + token_id, + recipient_id, + .. + } => 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. TokenOperationType::TokenMintToPool { token_id, .. } @@ -689,7 +719,8 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { | TokenOperationType::TokenMarkPerpetualReleaseAsDistributed { .. } | TokenOperationType::TokenMarkPreProgrammedReleaseAsDistributed { .. } | TokenOperationType::TokenMarkOncePerIdentityReleaseAsDistributed { .. } - | TokenOperationType::TokenHistory { .. } => vec![], + | TokenOperationType::TokenHistory { .. } + | TokenOperationType::TokenRandomAmountHistory { .. } => vec![], } } @@ -1860,6 +1891,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 99ff837d42f..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 @@ -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,63 @@ 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, + }, + /// 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, 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, + /// 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 { @@ -289,6 +347,7 @@ impl DriveLowLevelOperationConverter for TokenOperationType { owner_id, nonce, event, + None, block_info, estimated_costs_only_with_layer_info, transaction, @@ -296,6 +355,42 @@ impl DriveLowLevelOperationConverter for TokenOperationType { )?; Ok(batch_operations) } + TokenOperationType::TokenRandomAmountHistory { + token_id, + owner_id, + nonce, + event, + minimum, + maximum, + } => { + // 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, + event, + Some((minimum, maximum)), + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + batch_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(prepayment)); + Ok(batch_operations) + } TokenOperationType::TokenFreeze { token_id, frozen_identity_id, @@ -457,10 +552,125 @@ 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, 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, + 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 +1030,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/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 90e383617f5..d6e2a461fce 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; @@ -640,6 +642,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 @@ -688,9 +729,30 @@ 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. 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")?; + 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"]) } @@ -3691,6 +3753,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..035d4cdb2cc --- /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,694 @@ +//! 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. 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::{ + 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::group::GroupStateTransitionInfo; +use dpp::identity::accessors::IdentitySettersV0; +use dpp::identity::Identity; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; +use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; +use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use dpp::state_transition::batch_transition::token_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, +}; +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, +} + +/// 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, + 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 { + Self::executing(kind, Execution::Settled) + } + + /// [`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); + 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"); + + if let Execution::Fixed = execution { + 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, + ) + .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 (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 { + 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, + history_amount, + ), + 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(history_amount, RECIPIENT.into(), Some(NOTE.to_string())), + RandomTokenRequestKind::Mint { + recipient_id: RECIPIENT.into(), + }, + ), + }; + drive + .apply_drive_operations( + vec![ + DriveOperation::TokenOperation(reservation), + DriveOperation::TokenOperation(TokenOperationType::TokenRandomAmountHistory { + token_id, + owner_id: OWNER.into(), + nonce: NONCE, + event, + minimum: MINIMUM, + maximum: MAXIMUM, + }), + ], + true, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected the block to execute the random transition"); + + // The settlement draws the amount and rewrites the history document to hold it + 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, + 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 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(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, 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()))), + 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 { + 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_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, +) { + 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 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); + assert!( + (MINIMUM..=MAXIMUM).contains(&drawn), + "expected the settled amount {drawn} to lie in the range sent" + ); + + 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}" + ); + } +} + +/// 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, Execution::Fixed); + 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" + ); +} + +/// 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_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_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); +} + +#[test] +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] +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 93461710789..c1dc77a5dc7 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; @@ -55,6 +55,14 @@ pub struct DPPStateTransitionSerializationVersions { pub token_unshield_with_shielded_fee_state_transition: FeatureVersionBounds, pub token_purchase_from_shielded_pool_state_transition: FeatureVersionBounds, pub private_document_create_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 a0dbfb79fc9..a33b1dd07fe 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 @@ -209,4 +209,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 0b2dbb148e2..cdd5d9c1ff9 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 @@ -209,4 +209,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 f96754f6579..e747310a803 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 @@ -228,4 +228,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 650f5adbce7..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 @@ -14,6 +14,13 @@ 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, and + /// `prepay_random_token_history_entries`, the same generation's price of the history index + /// 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, } /// 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 ae0ad4a9a15..58786733b27 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -38,6 +38,9 @@ use grovedb_version::version::v5::GROVE_V5; /// * **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 @@ -131,7 +134,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-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 232b84cff7e..ff286a5ffbe 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -206,6 +206,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 diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 10ea7e50b3e..17b0773983a 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -904,9 +904,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. +/// +/// 42. **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: calculate_fee 1 prices a refund at the storing epoch's rate (note 27); 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 41); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, add_reference_for_index_level_for_contract_operations 1, 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); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26); verify methods v4: verify_proof_keep_serialized 1 verifies a document that may be contested (note 29); document method versions v5: single_document_path_query 1 selects the latest retained revision at or before a block time, and verify_proof_keep_serialized 1 verifies it (note 31); retained-history writers, readers, estimates and verifiers select the per-type revision layout (note 35); add_new_keys_to_masternode_identity 1 adds a masternode key whose hash the identity already holds a reference for (note 32); document method versions v5: add_indices_for_top_index_level_for_contract_operations 3 writes no entry in an expired time range bucket (note 36) + drive: DRIVE_VERSION_V10, // changed: calculate_fee 1 prices a refund at the storing epoch's rate (note 27); 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 41); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, add_reference_for_index_level_for_contract_operations 1, 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); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26); 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 42); verify methods v4: verify_proof_keep_serialized 1 verifies a document that may be contested (note 29); document method versions v5: single_document_path_query 1 selects the latest retained revision at or before a block time, and verify_proof_keep_serialized 1 verifies it (note 31); retained-history writers, readers, estimates and verifiers select the per-type revision layout (note 35); add_new_keys_to_masternode_identity 1 adds a masternode key whose hash the identity already holds a reference for (note 32); document method versions v5: add_indices_for_top_index_level_for_contract_operations 3 writes no entry in an expired time range bucket (note 36) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: perform_events_on_first_block_of_protocol_change 3 migrates retained history after operator-payout reconciliation and other activation work (note 35); 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); update_core_info 1 records the chain locked height at which Core's V24 is first seen active (note 18); build_untied_withdrawal_transactions_from_documents 1 emits asset unlock payload version 2 once V24 is recorded as active (note 20); update_voter_identity 1 re-enables the voter key of the address a masternode returns to (note 22); update_masternode_list 2 and update_operator_identity 1 make operator payouts and the operator identities' TRANSFER keys follow Core's diffs (note 32) 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 be0e36d27c8..47b280b12cc 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 @@ -536,6 +536,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"))?); } @@ -550,6 +558,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"))?, @@ -670,6 +686,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, @@ -1522,6 +1539,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 ff2ef1c3839..2d994a358d6 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; @@ -1477,6 +1477,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::DuplicatePrivateDocumentEntryError(e) => { generic_consensus_error!(DuplicatePrivateDocumentEntryError, 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()) 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); + }); + }); + }); + }); });