From 6f8f0dc28a371e7326da78dd2e1e46ba3f8d601d Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 20 Feb 2026 12:43:25 -0500 Subject: [PATCH 01/17] feat: add Hash192 type to binary codec Add a 192-bit (24-byte) hash type required by MPT fields such as MPTokenIssuanceID. Follows the same implementation pattern as Hash128/Hash160/Hash256 with full trait coverage (Hash, XRPLType, TryFromParser, TryFrom<&str>, Display, AsRef<[u8]>) and unit tests. --- src/core/binarycodec/types/hash.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/binarycodec/types/hash.rs b/src/core/binarycodec/types/hash.rs index a5829434..568abe25 100644 --- a/src/core/binarycodec/types/hash.rs +++ b/src/core/binarycodec/types/hash.rs @@ -505,11 +505,13 @@ mod test { fn accept_hash_invalid_length_errors() { let hash128 = Hash128::try_from("1000000000200000000030000000001234"); let hash160 = Hash160::try_from("100000000020000000003000000000400000000012"); + let hash192 = Hash192::try_from("10000000002000000000300000000040000000005000000012"); let hash256 = Hash256::try_from("100000000020000000003000000000400000000050000000006000000000123456"); assert!(hash128.is_err()); assert!(hash160.is_err()); + assert!(hash192.is_err()); assert!(hash256.is_err()); } From 7d8b3a1d2f7c57c6f2a3cb04cc9542e63bad7a91 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 20 Feb 2026 13:59:28 -0500 Subject: [PATCH 02/17] feat: add MPT transaction type enum variants Add MPTokenAuthorize, MPTokenIssuanceCreate, MPTokenIssuanceDestroy, and MPTokenIssuanceSet to the TransactionType enum for use by MPT transaction models. --- src/models/transactions/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..27eaec2f 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -80,6 +80,10 @@ pub enum TransactionType { EscrowCancel, EscrowCreate, EscrowFinish, + MPTokenAuthorize, + MPTokenIssuanceCreate, + MPTokenIssuanceDestroy, + MPTokenIssuanceSet, NFTokenAcceptOffer, NFTokenBurn, NFTokenCancelOffer, From b0fa264ad999437aace7851034c426702b772ff2 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 20 Feb 2026 14:16:08 -0500 Subject: [PATCH 03/17] feat: add MPTokenIssuanceCreate transaction model Add the MPTokenIssuanceCreate transaction with fields for asset_scale, maximum_amount, transfer_fee, and mptoken_metadata. Includes flags (TfMPTCanLock, TfMPTRequireAuth, TfMPTCanEscrow, TfMPTCanTrade, TfMPTCanTransfer, TfMPTCanClawback), transfer fee validation, builder methods, and serde roundtrip tests. Also adds InvalidFlagCombination variant to XRPLModelException for use by MPTokenIssuanceSet validation. --- src/models/exceptions.rs | 2 + src/models/transactions/mod.rs | 4 + .../transactions/mptoken_issuance_create.rs | 361 ++++++++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 src/models/transactions/mptoken_issuance_create.rs diff --git a/src/models/exceptions.rs b/src/models/exceptions.rs index ccbc80e5..8f323911 100644 --- a/src/models/exceptions.rs +++ b/src/models/exceptions.rs @@ -73,6 +73,8 @@ pub enum XRPLModelException { #[error("Expected field `{0}` is missing")] MissingField(String), + #[error("The flags `{flag1:?}` and `{flag2:?}` cannot be set simultaneously")] + InvalidFlagCombination { flag1: String, flag2: String }, #[error("From hex error: {0}")] FromHexError(#[from] hex::FromHexError), diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 27eaec2f..d5fd3983 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -14,6 +14,10 @@ pub mod escrow_cancel; pub mod escrow_create; pub mod escrow_finish; pub mod exceptions; +pub mod mptoken_authorize; +pub mod mptoken_issuance_create; +pub mod mptoken_issuance_destroy; +pub mod mptoken_issuance_set; pub mod metadata; pub mod nftoken_accept_offer; pub mod nftoken_burn; diff --git a/src/models/transactions/mptoken_issuance_create.rs b/src/models/transactions/mptoken_issuance_create.rs new file mode 100644 index 00000000..85209f58 --- /dev/null +++ b/src/models/transactions/mptoken_issuance_create.rs @@ -0,0 +1,361 @@ +use alloc::borrow::Cow; +use alloc::vec::Vec; +use core::convert::TryFrom; + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; + +use crate::models::{ + transactions::{Transaction, TransactionType}, + Model, ValidateCurrencies, XRPLModelException, XRPLModelResult, +}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// Maximum transfer fee value (50000 = 50.000%). +const MAX_MPT_TRANSFER_FEE: u16 = 50000; + +/// Transactions of the MPTokenIssuanceCreate type support additional values +/// in the Flags field. +/// +/// See MPTokenIssuanceCreate flags: +/// `` +#[derive( + Debug, Eq, PartialEq, Copy, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, +)] +#[repr(u32)] +pub enum MPTokenIssuanceCreateFlag { + /// If set, indicates that the MPT can be locked both at an issuance + /// and individual level. + TfMPTCanLock = 0x00000002, + /// If set, indicates that individual holders must be authorized before + /// they can hold the MPT. + TfMPTRequireAuth = 0x00000004, + /// If set, indicates that this MPT can be transferred to other accounts. + TfMPTCanEscrow = 0x00000008, + /// If set, indicates that this MPT can be traded on the DEX. + TfMPTCanTrade = 0x00000010, + /// If set, indicates that the MPT can be transferred between accounts. + TfMPTCanTransfer = 0x00000020, + /// If set, indicates that the issuer can claw back the MPT. + TfMPTCanClawback = 0x00000040, +} + +impl TryFrom for MPTokenIssuanceCreateFlag { + type Error = (); + + fn try_from(value: u32) -> Result { + match value { + 0x00000002 => Ok(MPTokenIssuanceCreateFlag::TfMPTCanLock), + 0x00000004 => Ok(MPTokenIssuanceCreateFlag::TfMPTRequireAuth), + 0x00000008 => Ok(MPTokenIssuanceCreateFlag::TfMPTCanEscrow), + 0x00000010 => Ok(MPTokenIssuanceCreateFlag::TfMPTCanTrade), + 0x00000020 => Ok(MPTokenIssuanceCreateFlag::TfMPTCanTransfer), + 0x00000040 => Ok(MPTokenIssuanceCreateFlag::TfMPTCanClawback), + _ => Err(()), + } + } +} + +impl MPTokenIssuanceCreateFlag { + pub fn from_bits(bits: u32) -> Vec { + let mut flags = Vec::new(); + if bits & 0x00000002 != 0 { + flags.push(MPTokenIssuanceCreateFlag::TfMPTCanLock); + } + if bits & 0x00000004 != 0 { + flags.push(MPTokenIssuanceCreateFlag::TfMPTRequireAuth); + } + if bits & 0x00000008 != 0 { + flags.push(MPTokenIssuanceCreateFlag::TfMPTCanEscrow); + } + if bits & 0x00000010 != 0 { + flags.push(MPTokenIssuanceCreateFlag::TfMPTCanTrade); + } + if bits & 0x00000020 != 0 { + flags.push(MPTokenIssuanceCreateFlag::TfMPTCanTransfer); + } + if bits & 0x00000040 != 0 { + flags.push(MPTokenIssuanceCreateFlag::TfMPTCanClawback); + } + flags + } +} + +/// Creates a new MPToken issuance on the XRPL. +/// +/// See MPTokenIssuanceCreate: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct MPTokenIssuanceCreate<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, MPTokenIssuanceCreateFlag>, + /// The number of decimal places for the MPT value. Must be in the + /// range 0-9. Defaults to 0 if not provided. + pub asset_scale: Option, + /// Maximum supply of the MPT as a string-encoded unsigned 64-bit integer. + pub maximum_amount: Option>, + /// Transfer fee charged by the issuer for secondary sales, in hundredths + /// of a basis point (0-50000, representing 0.000%-50.000%). + pub transfer_fee: Option, + /// Arbitrary hex-encoded metadata for the issuance. + #[serde(rename = "MPTokenMetadata")] + pub mptoken_metadata: Option>, +} + +impl<'a> Model for MPTokenIssuanceCreate<'a> { + fn get_errors(&self) -> XRPLModelResult<()> { + self._get_transfer_fee_error()?; + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, MPTokenIssuanceCreateFlag> for MPTokenIssuanceCreate<'a> { + fn has_flag(&self, flag: &MPTokenIssuanceCreateFlag) -> bool { + self.common_fields.has_flag(flag) + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, MPTokenIssuanceCreateFlag> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, MPTokenIssuanceCreateFlag> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, MPTokenIssuanceCreateFlag> for MPTokenIssuanceCreate<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, MPTokenIssuanceCreateFlag> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> MPTokenIssuanceCreate<'a> { + pub fn with_asset_scale(mut self, asset_scale: u8) -> Self { + self.asset_scale = Some(asset_scale); + self + } + + pub fn with_maximum_amount(mut self, maximum_amount: Cow<'a, str>) -> Self { + self.maximum_amount = Some(maximum_amount); + self + } + + pub fn with_transfer_fee(mut self, transfer_fee: u16) -> Self { + self.transfer_fee = Some(transfer_fee); + self + } + + pub fn with_mptoken_metadata(mut self, mptoken_metadata: Cow<'a, str>) -> Self { + self.mptoken_metadata = Some(mptoken_metadata); + self + } + + pub fn with_flag(mut self, flag: MPTokenIssuanceCreateFlag) -> Self { + self.common_fields.flags.0.push(flag); + self + } + + pub fn with_flags(mut self, flags: Vec) -> Self { + self.common_fields.flags = flags.into(); + self + } + + fn _get_transfer_fee_error(&self) -> XRPLModelResult<()> { + if let Some(transfer_fee) = self.transfer_fee { + if transfer_fee > MAX_MPT_TRANSFER_FEE { + Err(XRPLModelException::ValueTooHigh { + field: "transfer_fee".into(), + max: MAX_MPT_TRANSFER_FEE as u32, + found: transfer_fee as u32, + }) + } else { + Ok(()) + } + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use crate::models::Model; + + use super::*; + + #[test] + fn test_serde() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + fee: Some("10".into()), + flags: vec![MPTokenIssuanceCreateFlag::TfMPTCanTransfer].into(), + ..Default::default() + }, + asset_scale: Some(2), + maximum_amount: Some("1000000".into()), + transfer_fee: Some(314), + mptoken_metadata: Some("ABCD".into()), + }; + + let json_str = serde_json::to_string(&txn).unwrap(); + let deserialized: MPTokenIssuanceCreate = serde_json::from_str(&json_str).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_transfer_fee_error() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rU4EE1FskCPJw5QkLx1iGgdWiJa6HeqYyb".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + ..Default::default() + }, + transfer_fee: Some(50001), + ..Default::default() + }; + + assert!(txn.validate().is_err()); + assert_eq!( + txn.validate().unwrap_err().to_string().as_str(), + "The value of the field `\"transfer_fee\"` is defined above its maximum (max 50000, found 50001)" + ); + } + + #[test] + fn test_builder_pattern() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + ..Default::default() + }, + ..Default::default() + } + .with_asset_scale(6) + .with_maximum_amount("999999999".into()) + .with_transfer_fee(100) + .with_mptoken_metadata("CAFEBABE".into()) + .with_flags(vec![ + MPTokenIssuanceCreateFlag::TfMPTCanTransfer, + MPTokenIssuanceCreateFlag::TfMPTCanLock, + ]) + .with_fee("12".into()) + .with_sequence(42); + + assert_eq!(txn.asset_scale, Some(6)); + assert_eq!(txn.maximum_amount.as_deref(), Some("999999999")); + assert_eq!(txn.transfer_fee, Some(100)); + assert_eq!(txn.mptoken_metadata.as_deref(), Some("CAFEBABE")); + assert!(txn.has_flag(&MPTokenIssuanceCreateFlag::TfMPTCanTransfer)); + assert!(txn.has_flag(&MPTokenIssuanceCreateFlag::TfMPTCanLock)); + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_default() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + ..Default::default() + }, + ..Default::default() + }; + + assert!(txn.asset_scale.is_none()); + assert!(txn.maximum_amount.is_none()); + assert!(txn.transfer_fee.is_none()); + assert!(txn.mptoken_metadata.is_none()); + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_transfer_fee_at_max() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + ..Default::default() + }, + transfer_fee: Some(MAX_MPT_TRANSFER_FEE), + ..Default::default() + }; + + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_flag_try_from_u32() { + assert_eq!( + MPTokenIssuanceCreateFlag::try_from(0x00000002), + Ok(MPTokenIssuanceCreateFlag::TfMPTCanLock) + ); + assert_eq!( + MPTokenIssuanceCreateFlag::try_from(0x00000004), + Ok(MPTokenIssuanceCreateFlag::TfMPTRequireAuth) + ); + assert_eq!( + MPTokenIssuanceCreateFlag::try_from(0x00000008), + Ok(MPTokenIssuanceCreateFlag::TfMPTCanEscrow) + ); + assert_eq!( + MPTokenIssuanceCreateFlag::try_from(0x00000010), + Ok(MPTokenIssuanceCreateFlag::TfMPTCanTrade) + ); + assert_eq!( + MPTokenIssuanceCreateFlag::try_from(0x00000020), + Ok(MPTokenIssuanceCreateFlag::TfMPTCanTransfer) + ); + assert_eq!( + MPTokenIssuanceCreateFlag::try_from(0x00000040), + Ok(MPTokenIssuanceCreateFlag::TfMPTCanClawback) + ); + assert!(MPTokenIssuanceCreateFlag::try_from(0x00000001).is_err()); + assert!(MPTokenIssuanceCreateFlag::try_from(0x00000080).is_err()); + } + + #[test] + fn test_flag_from_bits() { + let flags = MPTokenIssuanceCreateFlag::from_bits(0x00000026); + assert_eq!(flags.len(), 3); + assert!(flags.contains(&MPTokenIssuanceCreateFlag::TfMPTCanLock)); + assert!(flags.contains(&MPTokenIssuanceCreateFlag::TfMPTRequireAuth)); + assert!(flags.contains(&MPTokenIssuanceCreateFlag::TfMPTCanTransfer)); + + let empty = MPTokenIssuanceCreateFlag::from_bits(0); + assert!(empty.is_empty()); + + let all = MPTokenIssuanceCreateFlag::from_bits(0x0000007E); + assert_eq!(all.len(), 6); + } +} From a47f631f84b7d55d5b5d54ff266426828fc2c603 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 20 Feb 2026 14:16:19 -0500 Subject: [PATCH 04/17] feat: add MPTokenIssuanceDestroy transaction model Add the MPTokenIssuanceDestroy transaction with mptoken_issuance_id field. Uses NoFlags since this transaction has no flag-specific behavior. Includes builder method, serde roundtrip, and validation tests. --- .../transactions/mptoken_issuance_destroy.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/models/transactions/mptoken_issuance_destroy.rs diff --git a/src/models/transactions/mptoken_issuance_destroy.rs b/src/models/transactions/mptoken_issuance_destroy.rs new file mode 100644 index 00000000..643cdbd3 --- /dev/null +++ b/src/models/transactions/mptoken_issuance_destroy.rs @@ -0,0 +1,140 @@ +use alloc::borrow::Cow; + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ + transactions::{Transaction, TransactionType}, + Model, NoFlags, ValidateCurrencies, XRPLModelResult, +}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// Destroys an existing MPToken issuance. Only the issuer can destroy an +/// issuance, and only if there are no outstanding tokens held by others. +/// +/// See MPTokenIssuanceDestroy: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct MPTokenIssuanceDestroy<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The MPToken issuance ID to destroy, encoded as a hex string. + #[serde(rename = "MPTokenIssuanceID")] + pub mptoken_issuance_id: Cow<'a, str>, +} + +impl<'a> Model for MPTokenIssuanceDestroy<'a> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for MPTokenIssuanceDestroy<'a> { + fn has_flag(&self, flag: &NoFlags) -> bool { + self.common_fields.has_flag(flag) + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for MPTokenIssuanceDestroy<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> MPTokenIssuanceDestroy<'a> { + pub fn with_mptoken_issuance_id(mut self, id: Cow<'a, str>) -> Self { + self.mptoken_issuance_id = id; + self + } +} + +#[cfg(test)] +mod tests { + use crate::models::Model; + + use super::*; + + #[test] + fn test_serde() { + let txn = MPTokenIssuanceDestroy { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceDestroy, + fee: Some("10".into()), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + }; + + let json_str = serde_json::to_string(&txn).unwrap(); + let deserialized: MPTokenIssuanceDestroy = serde_json::from_str(&json_str).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_builder_pattern() { + let txn = MPTokenIssuanceDestroy { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceDestroy, + ..Default::default() + }, + ..Default::default() + } + .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A00".into()) + .with_fee("12".into()) + .with_sequence(100); + + assert_eq!( + txn.mptoken_issuance_id.as_ref(), + "00000001A407AF5856CEFBF81F3D4A00" + ); + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_default() { + let txn = MPTokenIssuanceDestroy { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceDestroy, + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + }; + + assert!(txn.validate().is_ok()); + } +} From ddb118caf7ff6de564470aa00ba65324cf2c8374 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 20 Feb 2026 14:16:36 -0500 Subject: [PATCH 05/17] feat: add MPTokenIssuanceSet transaction model Add the MPTokenIssuanceSet transaction with mptoken_issuance_id and optional holder fields. Includes TfMPTLock/TfMPTUnlock flags with validation that prevents setting both simultaneously. Builder methods, serde roundtrip, and conflict detection tests included. --- .../transactions/mptoken_issuance_set.rs | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/models/transactions/mptoken_issuance_set.rs diff --git a/src/models/transactions/mptoken_issuance_set.rs b/src/models/transactions/mptoken_issuance_set.rs new file mode 100644 index 00000000..725603b2 --- /dev/null +++ b/src/models/transactions/mptoken_issuance_set.rs @@ -0,0 +1,271 @@ +use alloc::borrow::Cow; +use alloc::vec::Vec; +use core::convert::TryFrom; + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; + +use crate::models::{ + transactions::{Transaction, TransactionType}, + Model, ValidateCurrencies, XRPLModelException, XRPLModelResult, +}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// Transactions of the MPTokenIssuanceSet type support additional values +/// in the Flags field. +/// +/// See MPTokenIssuanceSet flags: +/// `` +#[derive( + Debug, Eq, PartialEq, Copy, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, +)] +#[repr(u32)] +pub enum MPTokenIssuanceSetFlag { + /// Lock the MPT at the issuance or individual holder level. + TfMPTLock = 0x00000001, + /// Unlock the MPT at the issuance or individual holder level. + TfMPTUnlock = 0x00000002, +} + +impl TryFrom for MPTokenIssuanceSetFlag { + type Error = (); + + fn try_from(value: u32) -> Result { + match value { + 0x00000001 => Ok(MPTokenIssuanceSetFlag::TfMPTLock), + 0x00000002 => Ok(MPTokenIssuanceSetFlag::TfMPTUnlock), + _ => Err(()), + } + } +} + +impl MPTokenIssuanceSetFlag { + pub fn from_bits(bits: u32) -> Vec { + let mut flags = Vec::new(); + if bits & 0x00000001 != 0 { + flags.push(MPTokenIssuanceSetFlag::TfMPTLock); + } + if bits & 0x00000002 != 0 { + flags.push(MPTokenIssuanceSetFlag::TfMPTUnlock); + } + flags + } +} + +/// Modifies properties of an existing MPToken issuance, such as locking +/// or unlocking tokens at the issuance or individual holder level. +/// +/// See MPTokenIssuanceSet: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct MPTokenIssuanceSet<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, MPTokenIssuanceSetFlag>, + /// The MPToken issuance ID to modify, encoded as a hex string. + #[serde(rename = "MPTokenIssuanceID")] + pub mptoken_issuance_id: Cow<'a, str>, + /// The holder whose tokens to lock/unlock. If omitted, the lock/unlock + /// applies to the entire issuance. + pub holder: Option>, +} + +impl<'a> Model for MPTokenIssuanceSet<'a> { + fn get_errors(&self) -> XRPLModelResult<()> { + self._get_flag_error()?; + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, MPTokenIssuanceSetFlag> for MPTokenIssuanceSet<'a> { + fn has_flag(&self, flag: &MPTokenIssuanceSetFlag) -> bool { + self.common_fields.has_flag(flag) + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, MPTokenIssuanceSetFlag> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, MPTokenIssuanceSetFlag> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, MPTokenIssuanceSetFlag> for MPTokenIssuanceSet<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, MPTokenIssuanceSetFlag> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> MPTokenIssuanceSet<'a> { + pub fn with_mptoken_issuance_id(mut self, id: Cow<'a, str>) -> Self { + self.mptoken_issuance_id = id; + self + } + + pub fn with_holder(mut self, holder: Cow<'a, str>) -> Self { + self.holder = Some(holder); + self + } + + pub fn with_flag(mut self, flag: MPTokenIssuanceSetFlag) -> Self { + self.common_fields.flags.0.push(flag); + self + } + + pub fn with_flags(mut self, flags: Vec) -> Self { + self.common_fields.flags = flags.into(); + self + } + + fn _get_flag_error(&self) -> XRPLModelResult<()> { + let has_lock = self.has_flag(&MPTokenIssuanceSetFlag::TfMPTLock); + let has_unlock = self.has_flag(&MPTokenIssuanceSetFlag::TfMPTUnlock); + if has_lock && has_unlock { + Err(XRPLModelException::InvalidFlagCombination { + flag1: "TfMPTLock".into(), + flag2: "TfMPTUnlock".into(), + }) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use crate::models::Model; + + use super::*; + + #[test] + fn test_serde() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + fee: Some("10".into()), + flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + holder: Some("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()), + }; + + let json_str = serde_json::to_string(&txn).unwrap(); + let deserialized: MPTokenIssuanceSet = serde_json::from_str(&json_str).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_lock_unlock_conflict() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + flags: vec![ + MPTokenIssuanceSetFlag::TfMPTLock, + MPTokenIssuanceSetFlag::TfMPTUnlock, + ] + .into(), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + ..Default::default() + }; + + assert!(txn.validate().is_err()); + } + + #[test] + fn test_builder_pattern() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + ..Default::default() + }, + ..Default::default() + } + .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A00".into()) + .with_holder("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()) + .with_flag(MPTokenIssuanceSetFlag::TfMPTLock) + .with_fee("12".into()); + + assert_eq!( + txn.mptoken_issuance_id.as_ref(), + "00000001A407AF5856CEFBF81F3D4A00" + ); + assert_eq!( + txn.holder.as_deref(), + Some("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh") + ); + assert!(txn.has_flag(&MPTokenIssuanceSetFlag::TfMPTLock)); + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_default() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + ..Default::default() + }; + + assert!(txn.holder.is_none()); + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_flag_try_from_u32() { + assert_eq!( + MPTokenIssuanceSetFlag::try_from(0x00000001), + Ok(MPTokenIssuanceSetFlag::TfMPTLock) + ); + assert_eq!( + MPTokenIssuanceSetFlag::try_from(0x00000002), + Ok(MPTokenIssuanceSetFlag::TfMPTUnlock) + ); + assert!(MPTokenIssuanceSetFlag::try_from(0x00000004).is_err()); + } + + #[test] + fn test_flag_from_bits() { + let flags = MPTokenIssuanceSetFlag::from_bits(0x00000003); + assert_eq!(flags.len(), 2); + assert!(flags.contains(&MPTokenIssuanceSetFlag::TfMPTLock)); + assert!(flags.contains(&MPTokenIssuanceSetFlag::TfMPTUnlock)); + + let empty = MPTokenIssuanceSetFlag::from_bits(0); + assert!(empty.is_empty()); + } +} From a4d7a632046c13e6756f44d1d40237f31f42c3f4 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 20 Feb 2026 14:16:48 -0500 Subject: [PATCH 06/17] feat: add MPTokenAuthorize transaction model Add the MPTokenAuthorize transaction with mptoken_issuance_id and optional holder fields. Includes TfMPTUnauthorize flag for opt-out flows. Builder methods, serde roundtrip, holder opt-in, and deauthorize flow tests included. --- src/models/transactions/mptoken_authorize.rs | 243 +++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 src/models/transactions/mptoken_authorize.rs diff --git a/src/models/transactions/mptoken_authorize.rs b/src/models/transactions/mptoken_authorize.rs new file mode 100644 index 00000000..8aca85fe --- /dev/null +++ b/src/models/transactions/mptoken_authorize.rs @@ -0,0 +1,243 @@ +use alloc::borrow::Cow; +use alloc::vec::Vec; +use core::convert::TryFrom; + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; + +use crate::models::{ + transactions::{Transaction, TransactionType}, + Model, ValidateCurrencies, XRPLModelResult, +}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// Transactions of the MPTokenAuthorize type support additional values +/// in the Flags field. +/// +/// See MPTokenAuthorize flags: +/// `` +#[derive( + Debug, Eq, PartialEq, Copy, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, +)] +#[repr(u32)] +pub enum MPTokenAuthorizeFlag { + /// If set, revokes authorization (deauthorize / opt out). + TfMPTUnauthorize = 0x00000001, +} + +impl TryFrom for MPTokenAuthorizeFlag { + type Error = (); + + fn try_from(value: u32) -> Result { + match value { + 0x00000001 => Ok(MPTokenAuthorizeFlag::TfMPTUnauthorize), + _ => Err(()), + } + } +} + +impl MPTokenAuthorizeFlag { + pub fn from_bits(bits: u32) -> Vec { + let mut flags = Vec::new(); + if bits & 0x00000001 != 0 { + flags.push(MPTokenAuthorizeFlag::TfMPTUnauthorize); + } + flags + } +} + +/// Authorizes an account to hold tokens from an MPToken issuance, or +/// (when sent by the issuer) authorizes a holder to participate. +/// +/// See MPTokenAuthorize: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct MPTokenAuthorize<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, MPTokenAuthorizeFlag>, + /// The MPToken issuance ID to authorize for, encoded as a hex string. + #[serde(rename = "MPTokenIssuanceID")] + pub mptoken_issuance_id: Cow<'a, str>, + /// The holder to authorize. Omitted when a holder opts in themselves; + /// provided when the issuer authorizes a specific holder. + pub holder: Option>, +} + +impl<'a> Model for MPTokenAuthorize<'a> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, MPTokenAuthorizeFlag> for MPTokenAuthorize<'a> { + fn has_flag(&self, flag: &MPTokenAuthorizeFlag) -> bool { + self.common_fields.has_flag(flag) + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, MPTokenAuthorizeFlag> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, MPTokenAuthorizeFlag> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, MPTokenAuthorizeFlag> for MPTokenAuthorize<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, MPTokenAuthorizeFlag> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> MPTokenAuthorize<'a> { + pub fn with_mptoken_issuance_id(mut self, id: Cow<'a, str>) -> Self { + self.mptoken_issuance_id = id; + self + } + + pub fn with_holder(mut self, holder: Cow<'a, str>) -> Self { + self.holder = Some(holder); + self + } + + pub fn with_flag(mut self, flag: MPTokenAuthorizeFlag) -> Self { + self.common_fields.flags.0.push(flag); + self + } + + pub fn with_flags(mut self, flags: Vec) -> Self { + self.common_fields.flags = flags.into(); + self + } +} + +#[cfg(test)] +mod tests { + use crate::models::Model; + + use super::*; + + #[test] + fn test_serde() { + let txn = MPTokenAuthorize { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenAuthorize, + fee: Some("10".into()), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + holder: Some("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()), + }; + + let json_str = serde_json::to_string(&txn).unwrap(); + let deserialized: MPTokenAuthorize = serde_json::from_str(&json_str).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_holder_opt_in() { + let txn = MPTokenAuthorize { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenAuthorize, + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + ..Default::default() + }; + + assert!(txn.holder.is_none()); + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_builder_pattern() { + let txn = MPTokenAuthorize { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenAuthorize, + ..Default::default() + }, + ..Default::default() + } + .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A00".into()) + .with_holder("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()) + .with_flag(MPTokenAuthorizeFlag::TfMPTUnauthorize) + .with_fee("12".into()); + + assert_eq!( + txn.mptoken_issuance_id.as_ref(), + "00000001A407AF5856CEFBF81F3D4A00" + ); + assert_eq!( + txn.holder.as_deref(), + Some("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh") + ); + assert!(txn.has_flag(&MPTokenAuthorizeFlag::TfMPTUnauthorize)); + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_deauthorize_flow() { + let txn = MPTokenAuthorize { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenAuthorize, + flags: vec![MPTokenAuthorizeFlag::TfMPTUnauthorize].into(), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + ..Default::default() + }; + + assert!(txn.has_flag(&MPTokenAuthorizeFlag::TfMPTUnauthorize)); + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_flag_try_from_u32() { + assert_eq!( + MPTokenAuthorizeFlag::try_from(0x00000001), + Ok(MPTokenAuthorizeFlag::TfMPTUnauthorize) + ); + assert!(MPTokenAuthorizeFlag::try_from(0x00000002).is_err()); + assert!(MPTokenAuthorizeFlag::try_from(0).is_err()); + } + + #[test] + fn test_flag_from_bits() { + let flags = MPTokenAuthorizeFlag::from_bits(0x00000001); + assert_eq!(flags.len(), 1); + assert!(flags.contains(&MPTokenAuthorizeFlag::TfMPTUnauthorize)); + + let empty = MPTokenAuthorizeFlag::from_bits(0); + assert!(empty.is_empty()); + } +} From bbf00aa7188edb7d709a8e1bb7c02c0c49e00c33 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 20 Feb 2026 14:29:28 -0500 Subject: [PATCH 07/17] feat: add MPToken and MPTokenIssuance ledger objects Add MPToken (account balance) and MPTokenIssuance (issuance definition) ledger entry types with full serde support, LedgerEntryType enum variants, and LedgerEntry enum variants. Includes serde roundtrip and ledger entry type tests for both objects. --- src/models/ledger/objects/mod.rs | 8 + src/models/ledger/objects/mptoken.rs | 100 ++++++++++++ src/models/ledger/objects/mptoken_issuance.rs | 143 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 src/models/ledger/objects/mptoken.rs create mode 100644 src/models/ledger/objects/mptoken_issuance.rs diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index 45fa941f..54ce8b8a 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -8,6 +8,8 @@ pub mod directory_node; pub mod escrow; pub mod fee_settings; pub mod ledger_hashes; +pub mod mptoken; +pub mod mptoken_issuance; pub mod negative_unl; pub mod nftoken_offer; pub mod nftoken_page; @@ -30,6 +32,8 @@ use directory_node::DirectoryNode; use escrow::Escrow; use fee_settings::FeeSettings; use ledger_hashes::LedgerHashes; +use mptoken::MPToken; +use mptoken_issuance::MPTokenIssuance; use negative_unl::NegativeUNL; use nftoken_offer::NFTokenOffer; use nftoken_page::NFTokenPage; @@ -62,6 +66,8 @@ pub enum LedgerEntryType { Escrow = 0x0075, FeeSettings = 0x0073, LedgerHashes = 0x0068, + MPToken = 0x007F, + MPTokenIssuance = 0x007E, NegativeUNL = 0x004E, NFTokenOffer = 0x0037, NFTokenPage = 0x0050, @@ -86,6 +92,8 @@ pub enum LedgerEntry<'a> { Escrow(Escrow<'a>), FeeSettings(FeeSettings<'a>), LedgerHashes(LedgerHashes<'a>), + MPToken(MPToken<'a>), + MPTokenIssuance(MPTokenIssuance<'a>), NegativeUNL(NegativeUNL<'a>), NFTokenOffer(NFTokenOffer<'a>), NFTokenPage(NFTokenPage<'a>), diff --git a/src/models/ledger/objects/mptoken.rs b/src/models/ledger/objects/mptoken.rs new file mode 100644 index 00000000..9a24ca35 --- /dev/null +++ b/src/models/ledger/objects/mptoken.rs @@ -0,0 +1,100 @@ +use alloc::borrow::Cow; + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ledger::objects::LedgerEntryType, Model, NoFlags}; + +use super::{CommonFields, LedgerObject}; + +/// The `MPToken` ledger object represents a single account's holdings of a +/// specific Multi-Purpose Token issuance. +/// +/// `` +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct MPToken<'a> { + /// The base fields for all ledger object models. + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The account that holds this MPT balance. + pub account: Cow<'a, str>, + /// The issuance ID identifying which MPT this balance belongs to. + #[serde(rename = "MPTokenIssuanceID")] + pub mptoken_issuance_id: Cow<'a, str>, + /// The amount of tokens held by this account. + #[serde(rename = "MPTAmount")] + pub mpt_amount: Cow<'a, str>, + /// Hash of the most recent transaction that modified this object. + #[serde(rename = "PreviousTxnID")] + pub previous_txn_id: Cow<'a, str>, + /// Ledger index of the most recent transaction that modified this object. + pub previous_txn_lgr_seq: u32, + /// The page in the owner's directory where this entry is located. + pub owner_node: Option>, +} + +impl<'a> Model for MPToken<'a> {} + +impl<'a> LedgerObject for MPToken<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +#[cfg(test)] +mod tests { + use alloc::borrow::Cow; + use alloc::vec; + + use crate::models::FlagCollection; + + use super::*; + + #[test] + fn test_serde() { + let mptoken = MPToken { + common_fields: CommonFields { + flags: FlagCollection(vec![]), + ledger_entry_type: LedgerEntryType::MPToken, + index: Some(Cow::from( + "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769", + )), + ledger_index: None, + }, + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mpt_amount: "1000".into(), + previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879" + .into(), + previous_txn_lgr_seq: 123456, + owner_node: Some("0".into()), + }; + + let serialized = serde_json::to_string(&mptoken).unwrap(); + let deserialized: MPToken = serde_json::from_str(&serialized).unwrap(); + assert_eq!(mptoken, deserialized); + } + + #[test] + fn test_ledger_entry_type() { + let mptoken = MPToken { + common_fields: CommonFields { + flags: FlagCollection(vec![]), + ledger_entry_type: LedgerEntryType::MPToken, + index: None, + ledger_index: None, + }, + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mpt_amount: "0".into(), + previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879" + .into(), + previous_txn_lgr_seq: 100, + owner_node: None, + }; + + assert_eq!(mptoken.get_ledger_entry_type(), LedgerEntryType::MPToken); + } +} diff --git a/src/models/ledger/objects/mptoken_issuance.rs b/src/models/ledger/objects/mptoken_issuance.rs new file mode 100644 index 00000000..b13c6630 --- /dev/null +++ b/src/models/ledger/objects/mptoken_issuance.rs @@ -0,0 +1,143 @@ +use alloc::borrow::Cow; + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ledger::objects::LedgerEntryType, Model, NoFlags}; + +use super::{CommonFields, LedgerObject}; + +/// The `MPTokenIssuance` ledger object defines the properties and metadata of +/// a Multi-Purpose Token issuance on the XRP Ledger. +/// +/// `` +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct MPTokenIssuance<'a> { + /// The base fields for all ledger object models. + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The account that issued this MPT. + pub issuer: Cow<'a, str>, + /// The number of decimal places for this token's amounts. + pub asset_scale: Option, + /// The maximum amount of this token that can ever exist. + pub maximum_amount: Option>, + /// The total amount of this token currently in circulation. + pub outstanding_amount: Cow<'a, str>, + /// Transfer fee for this token (in hundredths of a basis point, 0-50000). + pub transfer_fee: Option, + /// Arbitrary metadata associated with this issuance (hex-encoded). + #[serde(rename = "MPTokenMetadata")] + pub mptoken_metadata: Option>, + /// The sequence number of the transaction that created this issuance. + pub sequence: u32, + /// The page in the owner's directory where this entry is located. + pub owner_node: Option>, + /// Hash of the most recent transaction that modified this object. + #[serde(rename = "PreviousTxnID")] + pub previous_txn_id: Cow<'a, str>, + /// Ledger index of the most recent transaction that modified this object. + pub previous_txn_lgr_seq: u32, +} + +impl<'a> Model for MPTokenIssuance<'a> {} + +impl<'a> LedgerObject for MPTokenIssuance<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +#[cfg(test)] +mod tests { + use alloc::borrow::Cow; + use alloc::vec; + + use crate::models::FlagCollection; + + use super::*; + + #[test] + fn test_serde() { + let issuance = MPTokenIssuance { + common_fields: CommonFields { + flags: FlagCollection(vec![]), + ledger_entry_type: LedgerEntryType::MPTokenIssuance, + index: Some(Cow::from( + "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769", + )), + ledger_index: None, + }, + issuer: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + asset_scale: Some(2), + maximum_amount: Some("1000000".into()), + outstanding_amount: "500000".into(), + transfer_fee: Some(314), + mptoken_metadata: Some("CAFEBABE".into()), + sequence: 42, + owner_node: Some("0".into()), + previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879" + .into(), + previous_txn_lgr_seq: 654321, + }; + + let serialized = serde_json::to_string(&issuance).unwrap(); + let deserialized: MPTokenIssuance = serde_json::from_str(&serialized).unwrap(); + assert_eq!(issuance, deserialized); + } + + #[test] + fn test_ledger_entry_type() { + let issuance = MPTokenIssuance { + common_fields: CommonFields { + flags: FlagCollection(vec![]), + ledger_entry_type: LedgerEntryType::MPTokenIssuance, + index: None, + ledger_index: None, + }, + issuer: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + asset_scale: None, + maximum_amount: None, + outstanding_amount: "0".into(), + transfer_fee: None, + mptoken_metadata: None, + sequence: 1, + owner_node: None, + previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879" + .into(), + previous_txn_lgr_seq: 100, + }; + + assert_eq!( + issuance.get_ledger_entry_type(), + LedgerEntryType::MPTokenIssuance + ); + } + + #[test] + fn test_minimal_issuance() { + let issuance = MPTokenIssuance { + common_fields: CommonFields { + flags: FlagCollection(vec![]), + ledger_entry_type: LedgerEntryType::MPTokenIssuance, + index: None, + ledger_index: None, + }, + issuer: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + asset_scale: None, + maximum_amount: None, + outstanding_amount: "0".into(), + transfer_fee: None, + mptoken_metadata: None, + sequence: 1, + owner_node: None, + previous_txn_id: "0000000000000000000000000000000000000000000000000000000000000000" + .into(), + previous_txn_lgr_seq: 0, + }; + + assert!(issuance.validate().is_ok()); + } +} From 777cf98e9f7ee32f05f24c41fd98189bf715e4a6 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 20 Feb 2026 15:48:29 -0500 Subject: [PATCH 08/17] feat: add binary codec encode/decode tests for MPT transactions Tests cover: - Field name encoding/decoding for all 7 new MPT fields (Hash192, AccountID, UInt8, UInt64, Blob types) - Transaction type code resolution (codes 54-57) - Ledger entry type code resolution (codes 126-127) - Field instance metadata validation - Full encode() roundtrip for all 4 MPT transaction types with hex pattern verification - encode_for_signing() with signing prefix - Flag serialization (combined TfMPTCanTransfer | TfMPTCanLock) - Deterministic encoding output --- src/core/binarycodec/mod.rs | 455 +++++++++++++++++- src/models/transactions/mptoken_authorize.rs | 2 + .../transactions/mptoken_issuance_create.rs | 2 +- .../transactions/mptoken_issuance_set.rs | 2 + 4 files changed, 455 insertions(+), 6 deletions(-) diff --git a/src/core/binarycodec/mod.rs b/src/core/binarycodec/mod.rs index 91801fc8..68a452a5 100644 --- a/src/core/binarycodec/mod.rs +++ b/src/core/binarycodec/mod.rs @@ -173,16 +173,461 @@ pub fn decode_ledger_data(hex_string: &str) -> XRPLCoreResult { decode_ledger_data_inner(hex_string) } -#[cfg(all(test, feature = "std"))] +#[cfg(test)] mod test { + use alloc::vec; + use super::*; - #[path = "binary_json_tests.rs"] + #[path = "test/binary_json_tests.rs"] mod binary_json_tests; - #[path = "binary_serializer_tests.rs"] + #[path = "test/binary_serializer_tests.rs"] mod binary_serializer_tests; - #[path = "tx_encode_decode_tests.rs"] + #[path = "test/tx_encode_decode_tests.rs"] mod tx_encode_decode_tests; - #[path = "x_address_tests.rs"] + #[path = "test/x_address_tests.rs"] mod x_address_tests; + + use crate::core::binarycodec::definitions::{ + get_field_instance, get_ledger_entry_type_code, get_transaction_type_code, + }; + use crate::core::binarycodec::utils::{decode_field_name, encode_field_name}; + use crate::models::transactions::{ + mptoken_authorize::MPTokenAuthorize, + mptoken_issuance_create::{MPTokenIssuanceCreate, MPTokenIssuanceCreateFlag}, + mptoken_issuance_destroy::MPTokenIssuanceDestroy, + mptoken_issuance_set::{MPTokenIssuanceSet, MPTokenIssuanceSetFlag}, + CommonFields, TransactionType, + }; + + // ── Field encoding / decoding ────────────────────────────────────── + + #[test] + fn test_mpt_field_name_encoding() { + // (field_name, expected_hex) + // Hash192 type_code=21 (>=16), AccountID type_code=8 (<16), + // UInt8 type_code=16 (>=16), UInt64 type_code=3, Blob type_code=7 + let cases = [ + ("MPTokenIssuanceID", "0115"), // Hash192(21), nth 1 → byte1=0x01, byte2=0x15 + ("ShareMPTID", "0215"), // Hash192(21), nth 2 → byte1=0x02, byte2=0x15 + ("Holder", "8B"), // AccountID(8), nth 11 → (8<<4)|11 = 0x8B + ("AssetScale", "0510"), // UInt8(16), nth 5 → byte1=0x05, byte2=0x10 + ("MaximumAmount", "3018"), // UInt64(3), nth 24 → (3<<4)=0x30, 0x18 + ("MPTAmount", "301A"), // UInt64(3), nth 26 → (3<<4)=0x30, 0x1A + ("MPTokenMetadata", "701E"), // Blob(7), nth 30 → (7<<4)=0x70, 0x1E + ]; + + for (field_name, expected_hex) in &cases { + let encoded = encode_field_name(field_name) + .unwrap_or_else(|e| panic!("failed to encode field {}: {:?}", field_name, e)); + let hex = hex::encode_upper(encoded); + assert_eq!( + &hex, expected_hex, + "encode mismatch for field {}", + field_name + ); + + let decoded = decode_field_name(expected_hex) + .unwrap_or_else(|e| panic!("failed to decode hex {}: {:?}", expected_hex, e)); + assert_eq!( + decoded, *field_name, + "decode mismatch for hex {}", + expected_hex + ); + } + } + + // ── Type code resolution ─────────────────────────────────────────── + + #[test] + fn test_mpt_transaction_type_codes() { + assert_eq!( + get_transaction_type_code("MPTokenIssuanceCreate"), + Some(&54) + ); + assert_eq!( + get_transaction_type_code("MPTokenIssuanceDestroy"), + Some(&55) + ); + assert_eq!(get_transaction_type_code("MPTokenIssuanceSet"), Some(&56)); + assert_eq!(get_transaction_type_code("MPTokenAuthorize"), Some(&57)); + } + + #[test] + fn test_mpt_ledger_entry_type_codes() { + assert_eq!(get_ledger_entry_type_code("MPTokenIssuance"), Some(&126)); + assert_eq!(get_ledger_entry_type_code("MPToken"), Some(&127)); + } + + // ── Field instance metadata ──────────────────────────────────────── + + #[test] + fn test_mpt_field_instances() { + let fi = get_field_instance("MPTokenIssuanceID").expect("MPTokenIssuanceID not found"); + assert_eq!(fi.associated_type, "Hash192"); + assert_eq!(fi.nth, 1); + assert!(fi.is_serialized); + assert!(fi.is_signing); + + let fi = get_field_instance("Holder").expect("Holder not found"); + assert_eq!(fi.associated_type, "AccountID"); + assert_eq!(fi.nth, 11); + assert!(fi.is_vl_encoded); + + let fi = get_field_instance("MPTAmount").expect("MPTAmount not found"); + assert_eq!(fi.associated_type, "UInt64"); + assert_eq!(fi.nth, 26); + + let fi = get_field_instance("MPTokenMetadata").expect("MPTokenMetadata not found"); + assert_eq!(fi.associated_type, "Blob"); + assert_eq!(fi.nth, 30); + assert!(fi.is_vl_encoded); + + let fi = get_field_instance("AssetScale").expect("AssetScale not found"); + assert_eq!(fi.associated_type, "UInt8"); + assert_eq!(fi.nth, 5); + + let fi = get_field_instance("MaximumAmount").expect("MaximumAmount not found"); + assert_eq!(fi.associated_type, "UInt64"); + assert_eq!(fi.nth, 24); + + let fi = get_field_instance("ShareMPTID").expect("ShareMPTID not found"); + assert_eq!(fi.associated_type, "Hash192"); + assert_eq!(fi.nth, 2); + } + + // ── Full transaction encoding ────────────────────────────────────── + + /// TransactionType is always the first serialized field (lowest ordinal). + /// For MPTokenIssuanceCreate (code 54 = 0x0036), the hex starts with + /// field ID 0x12 (UInt16, nth 2) followed by the 2-byte type code. + #[test] + fn test_encode_mptoken_issuance_create() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + fee: Some("10".into()), + sequence: Some(1), + ..Default::default() + }, + asset_scale: Some(2), + maximum_amount: Some("1000000".into()), + transfer_fee: Some(314), + mptoken_metadata: Some("CAFEBABE".into()), + }; + + let hex = encode(&txn).expect("encode MPTokenIssuanceCreate failed"); + + // TransactionType field: 0x12 + 0x0036 (54) + assert!( + hex.starts_with("120036"), + "expected hex to start with 120036 (MPTokenIssuanceCreate), got: {}", + &hex[..core::cmp::min(20, hex.len())] + ); + + // TransferFee field: 0x14 + 0x013A (314) + assert!( + hex.contains("14013A"), + "expected TransferFee 314 (14013A) in hex" + ); + + // Flags = 0: 0x22 + 0x00000000 + assert!( + hex.contains("2200000000"), + "expected Flags 0 (2200000000) in hex" + ); + + // Sequence = 1: 0x24 + 0x00000001 + assert!( + hex.contains("2400000001"), + "expected Sequence 1 (2400000001) in hex" + ); + + // AssetScale = 2: field ID 0x0510 + 0x02 + assert!( + hex.contains("051002"), + "expected AssetScale 2 (051002) in hex" + ); + + // MPTokenMetadata (Blob): field ID 0x701E + length prefix + CAFEBABE + assert!( + hex.contains("CAFEBABE"), + "expected MPTokenMetadata hex payload in encoded output" + ); + } + + #[test] + fn test_encode_mptoken_issuance_create_with_flags() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + fee: Some("12".into()), + sequence: Some(5), + flags: vec![ + MPTokenIssuanceCreateFlag::TfMPTCanTransfer, + MPTokenIssuanceCreateFlag::TfMPTCanLock, + ] + .into(), + ..Default::default() + }, + ..Default::default() + }; + + let hex = encode(&txn).expect("encode MPTokenIssuanceCreate with flags failed"); + + assert!(hex.starts_with("120036"), "wrong transaction type"); + + // Flags = TfMPTCanTransfer (0x20) | TfMPTCanLock (0x02) = 0x22 + assert!( + hex.contains("2200000022"), + "expected Flags 0x22 (2200000022) in hex, got: {}", + hex + ); + } + + /// MPTokenIssuanceDestroy (code 55 = 0x0037) exercises Hash192 + /// serialization through the MPTokenIssuanceID field. + #[test] + fn test_encode_mptoken_issuance_destroy() { + let txn = MPTokenIssuanceDestroy { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenIssuanceDestroy, + fee: Some("10".into()), + sequence: Some(1), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344".into(), + }; + + let hex = encode(&txn).expect("encode MPTokenIssuanceDestroy failed"); + + // TransactionType = 55 = 0x0037 + assert!( + hex.starts_with("120037"), + "expected hex to start with 120037 (MPTokenIssuanceDestroy), got: {}", + &hex[..core::cmp::min(20, hex.len())] + ); + + // The Hash192 value should appear verbatim in the encoded hex + // (Hash192 is a fixed-length 24-byte field, no length prefix) + assert!( + hex.contains("00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344"), + "expected MPTokenIssuanceID hash in encoded output" + ); + } + + /// MPTokenIssuanceSet (code 56 = 0x0038) with the TfMPTLock flag + /// and Holder field (AccountID, nth 11). + #[test] + fn test_encode_mptoken_issuance_set() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + fee: Some("10".into()), + sequence: Some(1), + flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344".into(), + holder: Some("rPcHbQ26o4Xrwb2bu5gLc3gWUsS52yx1pG".into()), + }; + + let hex = encode(&txn).expect("encode MPTokenIssuanceSet failed"); + + // TransactionType = 56 = 0x0038 + assert!( + hex.starts_with("120038"), + "expected hex to start with 120038 (MPTokenIssuanceSet), got: {}", + &hex[..core::cmp::min(20, hex.len())] + ); + + // TfMPTLock = 0x00000001 + assert!( + hex.contains("2200000001"), + "expected Flags TfMPTLock (2200000001) in hex" + ); + + // Hash192 value in output + assert!( + hex.contains("00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344"), + "expected MPTokenIssuanceID in encoded output" + ); + + // Holder field (AccountID, 0x8B) should be present + assert!(hex.contains("8B"), "expected Holder field ID (8B) in hex"); + } + + /// MPTokenAuthorize (code 57 = 0x0039) with holder opt-in (no holder field). + #[test] + fn test_encode_mptoken_authorize() { + let txn = MPTokenAuthorize { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenAuthorize, + fee: Some("10".into()), + sequence: Some(1), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344".into(), + ..Default::default() + }; + + let hex = encode(&txn).expect("encode MPTokenAuthorize failed"); + + // TransactionType = 57 = 0x0039 + assert!( + hex.starts_with("120039"), + "expected hex to start with 120039 (MPTokenAuthorize), got: {}", + &hex[..core::cmp::min(20, hex.len())] + ); + + // Hash192 value + assert!( + hex.contains("00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344"), + "expected MPTokenIssuanceID in encoded output" + ); + } + + /// Verify that encode_for_signing adds the signing prefix and + /// excludes non-signing fields for MPT transactions. + #[test] + fn test_encode_for_signing_mptoken_issuance_create() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + fee: Some("10".into()), + sequence: Some(1), + ..Default::default() + }, + ..Default::default() + }; + + let hex = encode_for_signing(&txn).expect("encode_for_signing failed"); + + // Signing prefix: 0x53545800 + assert!( + hex.starts_with("53545800"), + "expected signing prefix 53545800, got: {}", + &hex[..core::cmp::min(20, hex.len())] + ); + + // TransactionType follows the prefix + assert!( + hex[8..].starts_with("120036"), + "expected TransactionType after signing prefix" + ); + } + + /// Verify that encode_for_multisigning adds the multisign prefix, + /// the signing account suffix, and excludes non-signing fields. + #[test] + fn test_encode_for_multisigning_mptoken_authorize() { + let txn = MPTokenAuthorize { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenAuthorize, + fee: Some("10".into()), + sequence: Some(1), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344".into(), + ..Default::default() + }; + + let hex = encode_for_multisigning(&txn, "rPcHbQ26o4Xrwb2bu5gLc3gWUsS52yx1pG".into()) + .expect("encode_for_multisigning failed"); + + // Multisign prefix: 0x534D5400 + assert!( + hex.starts_with("534D5400"), + "expected multisign prefix 534D5400, got: {}", + &hex[..core::cmp::min(20, hex.len())] + ); + + // TransactionType follows the prefix + assert!( + hex[8..].starts_with("120039"), + "expected MPTokenAuthorize type after multisign prefix" + ); + + // The signing account ID should appear at the end as a suffix + // (account ID is 20 bytes = 40 hex chars at end of encoded output) + assert!( + hex.len() > 40, + "encoded output too short for multisign suffix" + ); + } + + /// Encode the same transaction twice and verify deterministic output. + #[test] + fn test_encode_deterministic() { + let txn = MPTokenIssuanceDestroy { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::MPTokenIssuanceDestroy, + fee: Some("10".into()), + sequence: Some(42), + ..Default::default() + }, + mptoken_issuance_id: "AABBCCDD11223344AABBCCDD11223344AABBCCDD11223344".into(), + }; + + let hex1 = encode(&txn).expect("first encode failed"); + let hex2 = encode(&txn).expect("second encode failed"); + assert_eq!(hex1, hex2, "encoding should be deterministic"); + } + + /// Test that transaction encoding matches expected binary from xrpl.js fixtures. + #[cfg(feature = "std")] + #[test] + fn test_encode_additional_fixtures() { + use crate::core::binarycodec::test_cases::load_additional_tx_fixtures; + + let fixtures = load_additional_tx_fixtures(); + let total = fixtures.transactions.len(); + + println!( + "\n=== Running {} xrpl.js transaction fixture tests ===\n", + total + ); + + let mut passed = 0; + let mut failed = 0; + + for (i, fixture) in fixtures.transactions.iter().enumerate() { + let result = encode(&fixture.json); + + match result { + Ok(encoded) => { + if encoded.to_uppercase() == fixture.binary.to_uppercase() { + println!(" ✓ [{}/{}] {} passed", i + 1, total, fixture.name); + passed += 1; + } else { + println!(" ✗ [{}/{}] {} MISMATCH", i + 1, total, fixture.name); + println!(" Expected: {}", fixture.binary); + println!(" Got: {}", encoded); + failed += 1; + } + } + Err(e) => { + println!(" ✗ [{}/{}] {} FAILED: {:?}", i + 1, total, fixture.name, e); + failed += 1; + } + } + } + + println!( + "\n=== Results: {} passed, {} failed out of {} ===\n", + passed, failed, total + ); + + if failed > 0 { + panic!("{} out of {} tests failed", failed, total); + } + } } diff --git a/src/models/transactions/mptoken_authorize.rs b/src/models/transactions/mptoken_authorize.rs index 8aca85fe..f418e22c 100644 --- a/src/models/transactions/mptoken_authorize.rs +++ b/src/models/transactions/mptoken_authorize.rs @@ -139,6 +139,8 @@ impl<'a> MPTokenAuthorize<'a> { #[cfg(test)] mod tests { + use alloc::vec; + use crate::models::Model; use super::*; diff --git a/src/models/transactions/mptoken_issuance_create.rs b/src/models/transactions/mptoken_issuance_create.rs index 85209f58..1bb9a26e 100644 --- a/src/models/transactions/mptoken_issuance_create.rs +++ b/src/models/transactions/mptoken_issuance_create.rs @@ -205,7 +205,7 @@ impl<'a> MPTokenIssuanceCreate<'a> { #[cfg(test)] mod tests { - use alloc::string::ToString; + use alloc::{string::ToString, vec}; use crate::models::Model; diff --git a/src/models/transactions/mptoken_issuance_set.rs b/src/models/transactions/mptoken_issuance_set.rs index 725603b2..160230c0 100644 --- a/src/models/transactions/mptoken_issuance_set.rs +++ b/src/models/transactions/mptoken_issuance_set.rs @@ -159,6 +159,8 @@ impl<'a> MPTokenIssuanceSet<'a> { #[cfg(test)] mod tests { + use alloc::vec; + use crate::models::Model; use super::*; From e1fe79680692caf76cc092fa41b4fd555899365e Mon Sep 17 00:00:00 2001 From: e-desouza Date: Thu, 26 Mar 2026 15:34:11 -0400 Subject: [PATCH 09/17] style: fix module declaration order in transactions/mod.rs --- src/models/transactions/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index d5fd3983..0e1a2ccc 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -14,11 +14,11 @@ pub mod escrow_cancel; pub mod escrow_create; pub mod escrow_finish; pub mod exceptions; +pub mod metadata; pub mod mptoken_authorize; pub mod mptoken_issuance_create; pub mod mptoken_issuance_destroy; pub mod mptoken_issuance_set; -pub mod metadata; pub mod nftoken_accept_offer; pub mod nftoken_burn; pub mod nftoken_cancel_offer; From 194dfb1e7c45a782f95793f90c4c4c6b65253554 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 27 Mar 2026 23:23:02 -0400 Subject: [PATCH 10/17] fix(binarycodec): correct test module paths and remove unresolved fixture reference Post-rebase fixup on feat/mpt-binary-codec: - Fix #[path = "..."] attributes for external test modules: inline mod test{} resolves paths relative to the test/ subdirectory, not binarycodec/ - Remove test_encode_additional_fixtures which referenced load_additional_tx_fixtures() that does not exist in test_cases.rs --- src/core/binarycodec/mod.rs | 56 +++---------------------------------- 1 file changed, 4 insertions(+), 52 deletions(-) diff --git a/src/core/binarycodec/mod.rs b/src/core/binarycodec/mod.rs index 68a452a5..20466758 100644 --- a/src/core/binarycodec/mod.rs +++ b/src/core/binarycodec/mod.rs @@ -179,13 +179,13 @@ mod test { use super::*; - #[path = "test/binary_json_tests.rs"] + #[path = "binary_json_tests.rs"] mod binary_json_tests; - #[path = "test/binary_serializer_tests.rs"] + #[path = "binary_serializer_tests.rs"] mod binary_serializer_tests; - #[path = "test/tx_encode_decode_tests.rs"] + #[path = "tx_encode_decode_tests.rs"] mod tx_encode_decode_tests; - #[path = "test/x_address_tests.rs"] + #[path = "x_address_tests.rs"] mod x_address_tests; use crate::core::binarycodec::definitions::{ @@ -582,52 +582,4 @@ mod test { assert_eq!(hex1, hex2, "encoding should be deterministic"); } - /// Test that transaction encoding matches expected binary from xrpl.js fixtures. - #[cfg(feature = "std")] - #[test] - fn test_encode_additional_fixtures() { - use crate::core::binarycodec::test_cases::load_additional_tx_fixtures; - - let fixtures = load_additional_tx_fixtures(); - let total = fixtures.transactions.len(); - - println!( - "\n=== Running {} xrpl.js transaction fixture tests ===\n", - total - ); - - let mut passed = 0; - let mut failed = 0; - - for (i, fixture) in fixtures.transactions.iter().enumerate() { - let result = encode(&fixture.json); - - match result { - Ok(encoded) => { - if encoded.to_uppercase() == fixture.binary.to_uppercase() { - println!(" ✓ [{}/{}] {} passed", i + 1, total, fixture.name); - passed += 1; - } else { - println!(" ✗ [{}/{}] {} MISMATCH", i + 1, total, fixture.name); - println!(" Expected: {}", fixture.binary); - println!(" Got: {}", encoded); - failed += 1; - } - } - Err(e) => { - println!(" ✗ [{}/{}] {} FAILED: {:?}", i + 1, total, fixture.name, e); - failed += 1; - } - } - } - - println!( - "\n=== Results: {} passed, {} failed out of {} ===\n", - passed, failed, total - ); - - if failed > 0 { - panic!("{} out of {} tests failed", failed, total); - } - } } From 866ce443bdee73c748e63d8dbf12a1373182759d Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 27 Mar 2026 23:33:42 -0400 Subject: [PATCH 11/17] fix(binarycodec): gate tests on std and fix unused import in no_std builds - Change mod test gate from #[cfg(test)] to #[cfg(all(test, feature = "std"))] to match main's approach: the external test files and MPT encode tests all require serde_json/to_string which is unavailable in no_std builds - Tighten alloc::boxed::Box import in exceptions.rs to only compile when both no_std and websocket are active (Box only used by XRPLWebSocketError) --- src/asynch/clients/exceptions.rs | 2 +- src/core/binarycodec/mod.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/asynch/clients/exceptions.rs b/src/asynch/clients/exceptions.rs index 539bcdea..e6600810 100644 --- a/src/asynch/clients/exceptions.rs +++ b/src/asynch/clients/exceptions.rs @@ -1,4 +1,4 @@ -#[cfg(not(feature = "std"))] +#[cfg(all(not(feature = "std"), feature = "websocket"))] use alloc::boxed::Box; use thiserror_no_std::Error; diff --git a/src/core/binarycodec/mod.rs b/src/core/binarycodec/mod.rs index 20466758..a6107209 100644 --- a/src/core/binarycodec/mod.rs +++ b/src/core/binarycodec/mod.rs @@ -173,7 +173,7 @@ pub fn decode_ledger_data(hex_string: &str) -> XRPLCoreResult { decode_ledger_data_inner(hex_string) } -#[cfg(test)] +#[cfg(all(test, feature = "std"))] mod test { use alloc::vec; @@ -581,5 +581,4 @@ mod test { let hex2 = encode(&txn).expect("second encode failed"); assert_eq!(hex1, hex2, "encoding should be deterministic"); } - } From ed35ccef396117d7928cc8da2af68cb28a675b2e Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 3 Apr 2026 04:19:16 +0000 Subject: [PATCH 12/17] fix: add asset_scale range validation to MPTokenIssuanceCreate The asset_scale field is documented as accepting values 0-9, but rippled rejects values above 9. Without validation, values 10-255 would silently pass model validation and fail at submission time. Add a _get_asset_scale_error helper (mirroring _get_transfer_fee_error) and call it from get_errors(). Include tests for boundary values and the error message format. --- .../transactions/mptoken_issuance_create.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/models/transactions/mptoken_issuance_create.rs b/src/models/transactions/mptoken_issuance_create.rs index 1bb9a26e..3ff501f0 100644 --- a/src/models/transactions/mptoken_issuance_create.rs +++ b/src/models/transactions/mptoken_issuance_create.rs @@ -17,6 +17,9 @@ use super::{CommonFields, CommonTransactionBuilder}; /// Maximum transfer fee value (50000 = 50.000%). const MAX_MPT_TRANSFER_FEE: u16 = 50000; +/// Maximum asset scale value (rippled rejects values above 9). +const MAX_MPT_ASSET_SCALE: u8 = 9; + /// Transactions of the MPTokenIssuanceCreate type support additional values /// in the Flags field. /// @@ -123,6 +126,7 @@ pub struct MPTokenIssuanceCreate<'a> { impl<'a> Model for MPTokenIssuanceCreate<'a> { fn get_errors(&self) -> XRPLModelResult<()> { self._get_transfer_fee_error()?; + self._get_asset_scale_error()?; self.validate_currencies() } } @@ -201,6 +205,22 @@ impl<'a> MPTokenIssuanceCreate<'a> { Ok(()) } } + + fn _get_asset_scale_error(&self) -> XRPLModelResult<()> { + if let Some(scale) = self.asset_scale { + if scale > MAX_MPT_ASSET_SCALE { + Err(XRPLModelException::ValueTooHigh { + field: "asset_scale".into(), + max: MAX_MPT_ASSET_SCALE as u32, + found: scale as u32, + }) + } else { + Ok(()) + } + } else { + Ok(()) + } + } } #[cfg(test)] @@ -314,6 +334,55 @@ mod tests { assert!(txn.validate().is_ok()); } + #[test] + fn test_asset_scale_error() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rU4EE1FskCPJw5QkLx1iGgdWiJa6HeqYyb".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + ..Default::default() + }, + asset_scale: Some(10), + ..Default::default() + }; + + assert!(txn.validate().is_err()); + assert_eq!( + txn.validate().unwrap_err().to_string().as_str(), + "The value of the field `\"asset_scale\"` is defined above its maximum (max 9, found 10)" + ); + } + + #[test] + fn test_asset_scale_at_max() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + ..Default::default() + }, + asset_scale: Some(9), + ..Default::default() + }; + + assert!(txn.validate().is_ok()); + } + + #[test] + fn test_asset_scale_at_max_u8() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + ..Default::default() + }, + asset_scale: Some(255), + ..Default::default() + }; + + assert!(txn.validate().is_err()); + } + #[test] fn test_flag_try_from_u32() { assert_eq!( From d1d57da33ddff053ca39ac90218c6cbb17939b90 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 10 Apr 2026 15:43:54 +0000 Subject: [PATCH 13/17] fix: align MPToken/MPTokenIssuance field docs with xrpl.org, add integration tests Update field-level doc comments on MPToken and MPTokenIssuance ledger objects to match the canonical descriptions from xrpl.org. Add integration tests for all four MPToken transaction types: - MPTokenIssuanceCreate (base + with_metadata) - MPTokenAuthorize (holder opt-in) - MPTokenIssuanceSet (lock issuance) - MPTokenIssuanceDestroy (destroy empty issuance) Add create_mptoken_issuance() helper in tests/common that creates an issuance and returns the derived MPTokenIssuanceID for dependent tests. --- src/models/ledger/objects/mptoken.rs | 16 +++--- src/models/ledger/objects/mptoken_issuance.rs | 34 ++++++++---- tests/common/mod.rs | 43 +++++++++++++++ tests/transactions/mod.rs | 4 ++ tests/transactions/mptoken_authorize.rs | 38 +++++++++++++ tests/transactions/mptoken_issuance_create.rs | 54 +++++++++++++++++++ .../transactions/mptoken_issuance_destroy.rs | 36 +++++++++++++ tests/transactions/mptoken_issuance_set.rs | 40 ++++++++++++++ 8 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 tests/transactions/mptoken_authorize.rs create mode 100644 tests/transactions/mptoken_issuance_create.rs create mode 100644 tests/transactions/mptoken_issuance_destroy.rs create mode 100644 tests/transactions/mptoken_issuance_set.rs diff --git a/src/models/ledger/objects/mptoken.rs b/src/models/ledger/objects/mptoken.rs index 9a24ca35..4b2a73a8 100644 --- a/src/models/ledger/objects/mptoken.rs +++ b/src/models/ledger/objects/mptoken.rs @@ -18,20 +18,24 @@ pub struct MPToken<'a> { /// The base fields for all ledger object models. #[serde(flatten)] pub common_fields: CommonFields<'a, NoFlags>, - /// The account that holds this MPT balance. + /// The owner (holder) of these MPTs. pub account: Cow<'a, str>, - /// The issuance ID identifying which MPT this balance belongs to. + /// The `MPTokenIssuance` identifier. #[serde(rename = "MPTokenIssuanceID")] pub mptoken_issuance_id: Cow<'a, str>, - /// The amount of tokens held by this account. + /// The amount of tokens currently held by the owner. The minimum is 0 + /// and the maximum is 2^63-1. #[serde(rename = "MPTAmount")] pub mpt_amount: Cow<'a, str>, - /// Hash of the most recent transaction that modified this object. + /// The identifying hash of the transaction that most recently modified + /// this entry. #[serde(rename = "PreviousTxnID")] pub previous_txn_id: Cow<'a, str>, - /// Ledger index of the most recent transaction that modified this object. + /// The index of the ledger that contains the transaction that most + /// recently modified this object. pub previous_txn_lgr_seq: u32, - /// The page in the owner's directory where this entry is located. + /// A hint indicating which page of the owner directory links to this + /// entry, in case the directory consists of multiple pages. pub owner_node: Option>, } diff --git a/src/models/ledger/objects/mptoken_issuance.rs b/src/models/ledger/objects/mptoken_issuance.rs index b13c6630..761c7059 100644 --- a/src/models/ledger/objects/mptoken_issuance.rs +++ b/src/models/ledger/objects/mptoken_issuance.rs @@ -18,27 +18,41 @@ pub struct MPTokenIssuance<'a> { /// The base fields for all ledger object models. #[serde(flatten)] pub common_fields: CommonFields<'a, NoFlags>, - /// The account that issued this MPT. + /// The address of the account that controls both the issuance amounts + /// and characteristics of a particular fungible token. pub issuer: Cow<'a, str>, - /// The number of decimal places for this token's amounts. + /// An asset scale is the difference, in terms of orders of magnitude, + /// between a standard unit and a corresponding fractional unit. The + /// asset scale is a non-negative integer (0, 1, 2, ...) and defaults + /// to 0. pub asset_scale: Option, - /// The maximum amount of this token that can ever exist. + /// The maximum number of MPTs that can exist at one time. If omitted, + /// the maximum is currently limited to 2^63-1. pub maximum_amount: Option>, - /// The total amount of this token currently in circulation. + /// The total amount of MPTs of this issuance currently in circulation. + /// This value increases when the issuer sends MPTs to a non-issuer, and + /// decreases whenever the issuer receives MPTs. pub outstanding_amount: Cow<'a, str>, - /// Transfer fee for this token (in hundredths of a basis point, 0-50000). + /// This value specifies the fee, in tenths of a basis point, charged by + /// the issuer for secondary sales of the token, from 0 to 50,000 + /// inclusive (where 50,000 = 50%). pub transfer_fee: Option, - /// Arbitrary metadata associated with this issuance (hex-encoded). + /// Arbitrary metadata about this issuance, in hex format. The limit is + /// 1024 bytes. #[serde(rename = "MPTokenMetadata")] pub mptoken_metadata: Option>, - /// The sequence number of the transaction that created this issuance. + /// The Sequence (or Ticket) number of the transaction that created this + /// issuance, helping uniquely identify it. pub sequence: u32, - /// The page in the owner's directory where this entry is located. + /// A hint indicating which page of the owner directory links to this + /// entry. pub owner_node: Option>, - /// Hash of the most recent transaction that modified this object. + /// The identifying hash of the transaction that most recently modified + /// this entry. #[serde(rename = "PreviousTxnID")] pub previous_txn_id: Cow<'a, str>, - /// Ledger index of the most recent transaction that modified this object. + /// The index of the ledger that contains the transaction that most + /// recently modified this object. pub previous_txn_lgr_seq: u32, } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 14093264..faec01d2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -277,3 +277,46 @@ where ); ledger_accept().await; } + +/// Create an MPToken issuance and return the MPTokenIssuanceID. +/// +/// The ID is `{sequence as 4-byte BE hex}{account_id as 20-byte hex}`. +#[cfg(feature = "std")] +pub async fn create_mptoken_issuance(wallet: &Wallet) -> String { + use xrpl::asynch::transaction::sign_and_submit; + use xrpl::models::transactions::{ + mptoken_issuance_create::MPTokenIssuanceCreate, CommonFields, TransactionType, + }; + + let mut tx = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + fee: Some("10".into()), + ..Default::default() + }, + ..Default::default() + }; + + let client = get_client().await; + let result = sign_and_submit(&mut tx, client, wallet, true, true) + .await + .expect("create_mptoken_issuance: sign_and_submit failed"); + assert_eq!( + result.engine_result, "tesSUCCESS", + "create_mptoken_issuance: expected tesSUCCESS but got: {} — {}", + result.engine_result, result.engine_result_message + ); + ledger_accept().await; + + // Build MPTokenIssuanceID from the autofilled sequence + account ID + let sequence = result.tx_json["Sequence"] + .as_u64() + .expect("Sequence missing from tx_json") as u32; + let account_id = xrpl::core::addresscodec::decode_classic_address(&wallet.classic_address) + .expect("failed to decode classic address"); + let mut id_bytes = Vec::with_capacity(24); + id_bytes.extend_from_slice(&sequence.to_be_bytes()); + id_bytes.extend_from_slice(&account_id); + hex::encode_upper(&id_bytes) +} diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index c0e13b8d..4f3ab58b 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -13,6 +13,10 @@ pub mod deposit_preauth; pub mod escrow_cancel; pub mod escrow_create; pub mod escrow_finish; +pub mod mptoken_authorize; +pub mod mptoken_issuance_create; +pub mod mptoken_issuance_destroy; +pub mod mptoken_issuance_set; pub mod nftoken_accept_offer; pub mod nftoken_burn; pub mod nftoken_cancel_offer; diff --git a/tests/transactions/mptoken_authorize.rs b/tests/transactions/mptoken_authorize.rs new file mode 100644 index 00000000..54d0de61 --- /dev/null +++ b/tests/transactions/mptoken_authorize.rs @@ -0,0 +1,38 @@ +// xrpl.org reference: +// https://xrpl.org/docs/references/protocol/transactions/types/mptokenauthorize +// +// Scenarios: +// - holder_opt_in: a non-issuer authorizes themselves to hold the MPT + +use crate::common::{ + create_mptoken_issuance, generate_funded_wallet, test_transaction, with_blockchain_lock, +}; +use xrpl::models::transactions::{ + mptoken_authorize::MPTokenAuthorize, CommonFields, TransactionType, +}; + +#[tokio::test] +async fn test_mptoken_authorize_holder_opt_in() { + with_blockchain_lock(|| async { + let issuer = generate_funded_wallet().await; + let holder = generate_funded_wallet().await; + + // Create an issuance first + let issuance_id = create_mptoken_issuance(&issuer).await; + + // Holder opts in + let mut tx = MPTokenAuthorize { + common_fields: CommonFields { + account: holder.classic_address.clone().into(), + transaction_type: TransactionType::MPTokenAuthorize, + fee: Some("10".into()), + ..Default::default() + }, + mptoken_issuance_id: issuance_id.into(), + holder: None, // omitted when a holder opts in themselves + }; + + test_transaction(&mut tx, &holder).await; + }) + .await; +} diff --git a/tests/transactions/mptoken_issuance_create.rs b/tests/transactions/mptoken_issuance_create.rs new file mode 100644 index 00000000..053041d9 --- /dev/null +++ b/tests/transactions/mptoken_issuance_create.rs @@ -0,0 +1,54 @@ +// xrpl.org reference: +// https://xrpl.org/docs/references/protocol/transactions/types/mptokenissuancecreate +// +// Scenarios: +// - base: create an MPToken issuance with defaults +// - with_metadata: create with metadata and asset_scale + +use crate::common::{generate_funded_wallet, test_transaction, with_blockchain_lock}; +use xrpl::models::transactions::{ + mptoken_issuance_create::MPTokenIssuanceCreate, CommonFields, TransactionType, +}; + +#[tokio::test] +async fn test_mptoken_issuance_create_base() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + + let mut tx = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + fee: Some("10".into()), + ..Default::default() + }, + ..Default::default() + }; + + test_transaction(&mut tx, &wallet).await; + }) + .await; +} + +#[tokio::test] +async fn test_mptoken_issuance_create_with_metadata() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + + let mut tx = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + fee: Some("10".into()), + ..Default::default() + }, + asset_scale: Some(2), + maximum_amount: Some("1000000".into()), + transfer_fee: Some(314), + mptoken_metadata: Some("CAFEBABE".into()), + }; + + test_transaction(&mut tx, &wallet).await; + }) + .await; +} diff --git a/tests/transactions/mptoken_issuance_destroy.rs b/tests/transactions/mptoken_issuance_destroy.rs new file mode 100644 index 00000000..8ee6886d --- /dev/null +++ b/tests/transactions/mptoken_issuance_destroy.rs @@ -0,0 +1,36 @@ +// xrpl.org reference: +// https://xrpl.org/docs/references/protocol/transactions/types/mptokenissuancedestroy +// +// Scenarios: +// - base: issuer destroys an issuance with no outstanding tokens + +use crate::common::{ + create_mptoken_issuance, generate_funded_wallet, test_transaction, with_blockchain_lock, +}; +use xrpl::models::transactions::{ + mptoken_issuance_destroy::MPTokenIssuanceDestroy, CommonFields, TransactionType, +}; + +#[tokio::test] +async fn test_mptoken_issuance_destroy_base() { + with_blockchain_lock(|| async { + let issuer = generate_funded_wallet().await; + + // Create an issuance first + let issuance_id = create_mptoken_issuance(&issuer).await; + + // Destroy it (no outstanding tokens, so this should succeed) + let mut tx = MPTokenIssuanceDestroy { + common_fields: CommonFields { + account: issuer.classic_address.clone().into(), + transaction_type: TransactionType::MPTokenIssuanceDestroy, + fee: Some("10".into()), + ..Default::default() + }, + mptoken_issuance_id: issuance_id.into(), + }; + + test_transaction(&mut tx, &issuer).await; + }) + .await; +} diff --git a/tests/transactions/mptoken_issuance_set.rs b/tests/transactions/mptoken_issuance_set.rs new file mode 100644 index 00000000..10b8dfd9 --- /dev/null +++ b/tests/transactions/mptoken_issuance_set.rs @@ -0,0 +1,40 @@ +// xrpl.org reference: +// https://xrpl.org/docs/references/protocol/transactions/types/mptokenissuanceset +// +// Scenarios: +// - lock_issuance: issuer locks all tokens at the issuance level + +use crate::common::{ + create_mptoken_issuance, generate_funded_wallet, test_transaction, with_blockchain_lock, +}; +use xrpl::models::transactions::{ + mptoken_issuance_set::{MPTokenIssuanceSet, MPTokenIssuanceSetFlag}, + CommonFields, TransactionType, +}; +use xrpl::models::FlagCollection; + +#[tokio::test] +async fn test_mptoken_issuance_set_lock() { + with_blockchain_lock(|| async { + let issuer = generate_funded_wallet().await; + + // Create an issuance first + let issuance_id = create_mptoken_issuance(&issuer).await; + + // Lock the issuance + let mut tx = MPTokenIssuanceSet { + common_fields: CommonFields { + account: issuer.classic_address.clone().into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + fee: Some("10".into()), + flags: FlagCollection(vec![MPTokenIssuanceSetFlag::TfMPTLock]), + ..Default::default() + }, + mptoken_issuance_id: issuance_id.into(), + holder: None, // lock the entire issuance + }; + + test_transaction(&mut tx, &issuer).await; + }) + .await; +} From 44cc257feb881ffefad2597fc6d0d3b9d33727bd Mon Sep 17 00:00:00 2001 From: e-desouza Date: Thu, 16 Apr 2026 01:39:24 +0000 Subject: [PATCH 14/17] fix: use FlagCollection::from in integration test to respect pub(crate) visibility The FlagCollection tuple constructor is pub(crate), so integration tests (which live outside the crate) cannot use it directly. Use Vec::into() which invokes the public From> impl instead. --- tests/transactions/mptoken_issuance_set.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/transactions/mptoken_issuance_set.rs b/tests/transactions/mptoken_issuance_set.rs index 10b8dfd9..9baacd16 100644 --- a/tests/transactions/mptoken_issuance_set.rs +++ b/tests/transactions/mptoken_issuance_set.rs @@ -11,7 +11,6 @@ use xrpl::models::transactions::{ mptoken_issuance_set::{MPTokenIssuanceSet, MPTokenIssuanceSetFlag}, CommonFields, TransactionType, }; -use xrpl::models::FlagCollection; #[tokio::test] async fn test_mptoken_issuance_set_lock() { @@ -27,7 +26,7 @@ async fn test_mptoken_issuance_set_lock() { account: issuer.classic_address.clone().into(), transaction_type: TransactionType::MPTokenIssuanceSet, fee: Some("10".into()), - flags: FlagCollection(vec![MPTokenIssuanceSetFlag::TfMPTLock]), + flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), ..Default::default() }, mptoken_issuance_id: issuance_id.into(), From e1ccb15939a38a26a8ddff0b7e24f5b95dac06a5 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 17 Apr 2026 23:29:50 +0000 Subject: [PATCH 15/17] ci: pin rippled Docker image and harden health check wait The rippleci/rippled:develop image updated after 2026-04-01 and broke integration tests across all PRs (container exits before becoming healthy, causing Connection refused on localhost:5005). Pin to the last known-good digest and replace the simple until loop with a bounded retry that checks container liveness, prints status per attempt, and dumps container logs on failure. --- .github/workflows/integration_test.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 12e3df17..cd26abf8 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -11,7 +11,8 @@ on: name: Integration Test env: - RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop + # Pin to known-good digest; rippleci/rippled:develop broke after 2026-04-01 + RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop@sha256:328175bf14b7b83db9e5e6b50c7458bf828b02b2855453efc038233094aa8d85 jobs: integration_test: @@ -41,10 +42,22 @@ jobs: - name: Wait for rippled to be healthy run: | - until docker inspect --format='{{.State.Health.Status}}' rippled-service | grep -q healthy; do - echo "Waiting for rippled to be ready..." + for i in $(seq 1 30); do + if ! docker ps -q -f name=rippled-service | grep -q .; then + echo "Container exited unexpectedly" + docker logs rippled-service 2>&1 || true + exit 1 + fi + STATUS=$(docker inspect --format='{{.State.Health.Status}}' rippled-service 2>/dev/null || echo "unknown") + echo "Attempt $i/30: $STATUS" + if [ "$STATUS" = "healthy" ]; then + exit 0 + fi sleep 2 done + echo "Timed out waiting for rippled" + docker logs rippled-service 2>&1 || true + exit 1 - uses: dtolnay/rust-toolchain@stable From 067db6fd1ee7850565af7f78324ef358a01990cb Mon Sep 17 00:00:00 2001 From: e-desouza Date: Mon, 20 Apr 2026 05:19:13 +0000 Subject: [PATCH 16/17] test(mpt): let autofill compute fee and set required issuance flags Removing hardcoded fee: Some("10") lets sign_and_submit autofill compute the correct fee for the pinned rippled image (was causing telINSUF_FEE_P). Add TfMPTCanTransfer to the metadata test so transfer_fee is accepted (was temMALFORMED). Add TfMPTCanLock in the create_mptoken_issuance helper so the lock test has permission (was tecNO_PERMISSION). All 5 MPT integration tests now pass against rippled develop. --- tests/common/mod.rs | 5 +++-- tests/transactions/mptoken_authorize.rs | 1 - tests/transactions/mptoken_issuance_create.rs | 6 +++--- tests/transactions/mptoken_issuance_destroy.rs | 1 - tests/transactions/mptoken_issuance_set.rs | 1 - 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index faec01d2..a1c06905 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -285,14 +285,15 @@ where pub async fn create_mptoken_issuance(wallet: &Wallet) -> String { use xrpl::asynch::transaction::sign_and_submit; use xrpl::models::transactions::{ - mptoken_issuance_create::MPTokenIssuanceCreate, CommonFields, TransactionType, + mptoken_issuance_create::{MPTokenIssuanceCreate, MPTokenIssuanceCreateFlag}, + CommonFields, TransactionType, }; let mut tx = MPTokenIssuanceCreate { common_fields: CommonFields { account: wallet.classic_address.clone().into(), transaction_type: TransactionType::MPTokenIssuanceCreate, - fee: Some("10".into()), + flags: vec![MPTokenIssuanceCreateFlag::TfMPTCanLock].into(), ..Default::default() }, ..Default::default() diff --git a/tests/transactions/mptoken_authorize.rs b/tests/transactions/mptoken_authorize.rs index 54d0de61..70998f6a 100644 --- a/tests/transactions/mptoken_authorize.rs +++ b/tests/transactions/mptoken_authorize.rs @@ -25,7 +25,6 @@ async fn test_mptoken_authorize_holder_opt_in() { common_fields: CommonFields { account: holder.classic_address.clone().into(), transaction_type: TransactionType::MPTokenAuthorize, - fee: Some("10".into()), ..Default::default() }, mptoken_issuance_id: issuance_id.into(), diff --git a/tests/transactions/mptoken_issuance_create.rs b/tests/transactions/mptoken_issuance_create.rs index 053041d9..a6e07d76 100644 --- a/tests/transactions/mptoken_issuance_create.rs +++ b/tests/transactions/mptoken_issuance_create.rs @@ -7,7 +7,8 @@ use crate::common::{generate_funded_wallet, test_transaction, with_blockchain_lock}; use xrpl::models::transactions::{ - mptoken_issuance_create::MPTokenIssuanceCreate, CommonFields, TransactionType, + mptoken_issuance_create::{MPTokenIssuanceCreate, MPTokenIssuanceCreateFlag}, + CommonFields, TransactionType, }; #[tokio::test] @@ -19,7 +20,6 @@ async fn test_mptoken_issuance_create_base() { common_fields: CommonFields { account: wallet.classic_address.clone().into(), transaction_type: TransactionType::MPTokenIssuanceCreate, - fee: Some("10".into()), ..Default::default() }, ..Default::default() @@ -39,7 +39,7 @@ async fn test_mptoken_issuance_create_with_metadata() { common_fields: CommonFields { account: wallet.classic_address.clone().into(), transaction_type: TransactionType::MPTokenIssuanceCreate, - fee: Some("10".into()), + flags: vec![MPTokenIssuanceCreateFlag::TfMPTCanTransfer].into(), ..Default::default() }, asset_scale: Some(2), diff --git a/tests/transactions/mptoken_issuance_destroy.rs b/tests/transactions/mptoken_issuance_destroy.rs index 8ee6886d..7c41bb73 100644 --- a/tests/transactions/mptoken_issuance_destroy.rs +++ b/tests/transactions/mptoken_issuance_destroy.rs @@ -24,7 +24,6 @@ async fn test_mptoken_issuance_destroy_base() { common_fields: CommonFields { account: issuer.classic_address.clone().into(), transaction_type: TransactionType::MPTokenIssuanceDestroy, - fee: Some("10".into()), ..Default::default() }, mptoken_issuance_id: issuance_id.into(), diff --git a/tests/transactions/mptoken_issuance_set.rs b/tests/transactions/mptoken_issuance_set.rs index 9baacd16..0bec1b12 100644 --- a/tests/transactions/mptoken_issuance_set.rs +++ b/tests/transactions/mptoken_issuance_set.rs @@ -25,7 +25,6 @@ async fn test_mptoken_issuance_set_lock() { common_fields: CommonFields { account: issuer.classic_address.clone().into(), transaction_type: TransactionType::MPTokenIssuanceSet, - fee: Some("10".into()), flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), ..Default::default() }, From 0fa81dd6f4ec746da835fe3fc4f07161b0e8b27e Mon Sep 17 00:00:00 2001 From: e-desouza Date: Mon, 20 Apr 2026 05:33:49 +0000 Subject: [PATCH 17/17] fix(mpt): address PR review findings on XLS-33 models - Raise MAX_MPT_ASSET_SCALE from 9 to 19 to match rippled preflight (no hard cap; practical ceiling is bounded by maxMPTokenAmount near 2^63). - Replace NoFlags on the MPTokenIssuance and MPToken ledger objects with dedicated flag enums (MPTokenIssuanceFlag, MPTokenFlag) so the ledger flag bits returned by rippled are preserved instead of being dropped. - MPTokenIssuanceSet now requires exactly one of TfMPTLock or TfMPTUnlock (DomainID-only modifications are not yet modelled); the previous flipped test_default assertion is corrected and split into passing and failing cases. - Validate MPTokenIssuanceID across MPTokenIssuanceSet, MPTokenAuthorize, and MPTokenIssuanceDestroy as a 48-char ASCII hex string (24-byte Hash192 per XLS-33), and validate the optional holder field as a classic XRPL address. Unit-test fixtures updated to the spec-correct 48-char form; integration fixtures continue to build IDs dynamically. --- src/models/ledger/objects/mptoken.rs | 30 +++- src/models/ledger/objects/mptoken_issuance.rs | 36 ++++- src/models/transactions/mptoken_authorize.rs | 46 +++++- .../transactions/mptoken_issuance_create.rs | 11 +- .../transactions/mptoken_issuance_destroy.rs | 25 +++- .../transactions/mptoken_issuance_set.rs | 140 ++++++++++++++++-- 6 files changed, 254 insertions(+), 34 deletions(-) diff --git a/src/models/ledger/objects/mptoken.rs b/src/models/ledger/objects/mptoken.rs index 4b2a73a8..d268f428 100644 --- a/src/models/ledger/objects/mptoken.rs +++ b/src/models/ledger/objects/mptoken.rs @@ -1,12 +1,30 @@ use alloc::borrow::Cow; use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; -use crate::models::{ledger::objects::LedgerEntryType, Model, NoFlags}; +use crate::models::{ledger::objects::LedgerEntryType, Model}; use super::{CommonFields, LedgerObject}; +/// Ledger-object flags for the `MPToken` object. +/// +/// See `MPToken` flags: +/// `` +#[derive( + Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, +)] +#[repr(u32)] +pub enum MPTokenFlag { + /// This holder's MPToken balance is locked. + LsfMPTLocked = 0x0001, + /// This holder is authorized to hold the MPT. Set when the issuer + /// authorizes the holder via `MPTokenAuthorize`. + LsfMPTAuthorized = 0x0002, +} + /// The `MPToken` ledger object represents a single account's holdings of a /// specific Multi-Purpose Token issuance. /// @@ -17,7 +35,7 @@ use super::{CommonFields, LedgerObject}; pub struct MPToken<'a> { /// The base fields for all ledger object models. #[serde(flatten)] - pub common_fields: CommonFields<'a, NoFlags>, + pub common_fields: CommonFields<'a, MPTokenFlag>, /// The owner (holder) of these MPTs. pub account: Cow<'a, str>, /// The `MPTokenIssuance` identifier. @@ -41,7 +59,7 @@ pub struct MPToken<'a> { impl<'a> Model for MPToken<'a> {} -impl<'a> LedgerObject for MPToken<'a> { +impl<'a> LedgerObject for MPToken<'a> { fn get_ledger_entry_type(&self) -> LedgerEntryType { self.common_fields.get_ledger_entry_type() } @@ -60,7 +78,7 @@ mod tests { fn test_serde() { let mptoken = MPToken { common_fields: CommonFields { - flags: FlagCollection(vec![]), + flags: FlagCollection(vec![MPTokenFlag::LsfMPTAuthorized]), ledger_entry_type: LedgerEntryType::MPToken, index: Some(Cow::from( "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769", @@ -68,7 +86,7 @@ mod tests { ledger_index: None, }, account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), mpt_amount: "1000".into(), previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879" .into(), @@ -91,7 +109,7 @@ mod tests { ledger_index: None, }, account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), mpt_amount: "0".into(), previous_txn_id: "E3FE6EA3D48F0C2B639448020EA4F03D4F4F8FFDB243A852A0F59177921B4879" .into(), diff --git a/src/models/ledger/objects/mptoken_issuance.rs b/src/models/ledger/objects/mptoken_issuance.rs index 761c7059..004ba19e 100644 --- a/src/models/ledger/objects/mptoken_issuance.rs +++ b/src/models/ledger/objects/mptoken_issuance.rs @@ -1,12 +1,40 @@ use alloc::borrow::Cow; use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; -use crate::models::{ledger::objects::LedgerEntryType, Model, NoFlags}; +use crate::models::{ledger::objects::LedgerEntryType, Model}; use super::{CommonFields, LedgerObject}; +/// Ledger-object flags for the `MPTokenIssuance` object. +/// +/// See `MPTokenIssuance` flags: +/// `` +#[derive( + Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, +)] +#[repr(u32)] +pub enum MPTokenIssuanceFlag { + /// The MPT issuance is locked; no holder can send or receive tokens. + LsfMPTLocked = 0x0001, + /// The issuer retains the ability to lock the issuance or individual + /// holders. + LsfMPTCanLock = 0x0002, + /// Holders must be individually authorized before they can hold this MPT. + LsfMPTRequireAuth = 0x0004, + /// This MPT can be placed into escrows. + LsfMPTCanEscrow = 0x0008, + /// This MPT can be traded on the DEX. + LsfMPTCanTrade = 0x0010, + /// This MPT can be transferred between non-issuer holders. + LsfMPTCanTransfer = 0x0020, + /// The issuer can clawback MPTs from holders. + LsfMPTCanClawback = 0x0040, +} + /// The `MPTokenIssuance` ledger object defines the properties and metadata of /// a Multi-Purpose Token issuance on the XRP Ledger. /// @@ -17,7 +45,7 @@ use super::{CommonFields, LedgerObject}; pub struct MPTokenIssuance<'a> { /// The base fields for all ledger object models. #[serde(flatten)] - pub common_fields: CommonFields<'a, NoFlags>, + pub common_fields: CommonFields<'a, MPTokenIssuanceFlag>, /// The address of the account that controls both the issuance amounts /// and characteristics of a particular fungible token. pub issuer: Cow<'a, str>, @@ -58,7 +86,7 @@ pub struct MPTokenIssuance<'a> { impl<'a> Model for MPTokenIssuance<'a> {} -impl<'a> LedgerObject for MPTokenIssuance<'a> { +impl<'a> LedgerObject for MPTokenIssuance<'a> { fn get_ledger_entry_type(&self) -> LedgerEntryType { self.common_fields.get_ledger_entry_type() } @@ -77,7 +105,7 @@ mod tests { fn test_serde() { let issuance = MPTokenIssuance { common_fields: CommonFields { - flags: FlagCollection(vec![]), + flags: FlagCollection(vec![MPTokenIssuanceFlag::LsfMPTCanTransfer]), ledger_entry_type: LedgerEntryType::MPTokenIssuance, index: Some(Cow::from( "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769", diff --git a/src/models/transactions/mptoken_authorize.rs b/src/models/transactions/mptoken_authorize.rs index f418e22c..f9157145 100644 --- a/src/models/transactions/mptoken_authorize.rs +++ b/src/models/transactions/mptoken_authorize.rs @@ -12,6 +12,7 @@ use crate::models::{ Model, ValidateCurrencies, XRPLModelResult, }; +use super::mptoken_issuance_set::{validate_holder_address, validate_mptoken_issuance_id}; use super::{CommonFields, CommonTransactionBuilder}; /// Transactions of the MPTokenAuthorize type support additional values @@ -83,6 +84,10 @@ pub struct MPTokenAuthorize<'a> { impl<'a> Model for MPTokenAuthorize<'a> { fn get_errors(&self) -> XRPLModelResult<()> { + validate_mptoken_issuance_id(self.mptoken_issuance_id.as_ref())?; + if let Some(holder) = self.holder.as_deref() { + validate_holder_address(holder)?; + } self.validate_currencies() } } @@ -154,7 +159,7 @@ mod tests { fee: Some("10".into()), ..Default::default() }, - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), holder: Some("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()), }; @@ -171,7 +176,7 @@ mod tests { transaction_type: TransactionType::MPTokenAuthorize, ..Default::default() }, - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), ..Default::default() }; @@ -189,14 +194,14 @@ mod tests { }, ..Default::default() } - .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A00".into()) + .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into()) .with_holder("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()) .with_flag(MPTokenAuthorizeFlag::TfMPTUnauthorize) .with_fee("12".into()); assert_eq!( txn.mptoken_issuance_id.as_ref(), - "00000001A407AF5856CEFBF81F3D4A00" + "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58" ); assert_eq!( txn.holder.as_deref(), @@ -215,7 +220,7 @@ mod tests { flags: vec![MPTokenAuthorizeFlag::TfMPTUnauthorize].into(), ..Default::default() }, - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), ..Default::default() }; @@ -223,6 +228,37 @@ mod tests { assert!(txn.validate().is_ok()); } + #[test] + fn test_invalid_mptoken_issuance_id() { + let txn = MPTokenAuthorize { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenAuthorize, + ..Default::default() + }, + // 32 hex chars; must be 48. + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + ..Default::default() + }; + + assert!(txn.validate().is_err()); + } + + #[test] + fn test_invalid_holder_address() { + let txn = MPTokenAuthorize { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenAuthorize, + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), + holder: Some("not_a_valid_address".into()), + }; + + assert!(txn.validate().is_err()); + } + #[test] fn test_flag_try_from_u32() { assert_eq!( diff --git a/src/models/transactions/mptoken_issuance_create.rs b/src/models/transactions/mptoken_issuance_create.rs index 3ff501f0..2bd888a7 100644 --- a/src/models/transactions/mptoken_issuance_create.rs +++ b/src/models/transactions/mptoken_issuance_create.rs @@ -17,8 +17,9 @@ use super::{CommonFields, CommonTransactionBuilder}; /// Maximum transfer fee value (50000 = 50.000%). const MAX_MPT_TRANSFER_FEE: u16 = 50000; -/// Maximum asset scale value (rippled rejects values above 9). -const MAX_MPT_ASSET_SCALE: u8 = 9; +/// Maximum asset scale value. Rippled preflight does not cap this directly; +/// the practical ceiling is 19 (bounded by maxMPTokenAmount, approximately 2^63). +const MAX_MPT_ASSET_SCALE: u8 = 19; /// Transactions of the MPTokenIssuanceCreate type support additional values /// in the Flags field. @@ -342,14 +343,14 @@ mod tests { transaction_type: TransactionType::MPTokenIssuanceCreate, ..Default::default() }, - asset_scale: Some(10), + asset_scale: Some(20), ..Default::default() }; assert!(txn.validate().is_err()); assert_eq!( txn.validate().unwrap_err().to_string().as_str(), - "The value of the field `\"asset_scale\"` is defined above its maximum (max 9, found 10)" + "The value of the field `\"asset_scale\"` is defined above its maximum (max 19, found 20)" ); } @@ -361,7 +362,7 @@ mod tests { transaction_type: TransactionType::MPTokenIssuanceCreate, ..Default::default() }, - asset_scale: Some(9), + asset_scale: Some(19), ..Default::default() }; diff --git a/src/models/transactions/mptoken_issuance_destroy.rs b/src/models/transactions/mptoken_issuance_destroy.rs index 643cdbd3..bf48ca47 100644 --- a/src/models/transactions/mptoken_issuance_destroy.rs +++ b/src/models/transactions/mptoken_issuance_destroy.rs @@ -8,6 +8,7 @@ use crate::models::{ Model, NoFlags, ValidateCurrencies, XRPLModelResult, }; +use super::mptoken_issuance_set::validate_mptoken_issuance_id; use super::{CommonFields, CommonTransactionBuilder}; /// Destroys an existing MPToken issuance. Only the issuer can destroy an @@ -41,6 +42,7 @@ pub struct MPTokenIssuanceDestroy<'a> { impl<'a> Model for MPTokenIssuanceDestroy<'a> { fn get_errors(&self) -> XRPLModelResult<()> { + validate_mptoken_issuance_id(self.mptoken_issuance_id.as_ref())?; self.validate_currencies() } } @@ -95,7 +97,7 @@ mod tests { fee: Some("10".into()), ..Default::default() }, - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), }; let json_str = serde_json::to_string(&txn).unwrap(); @@ -113,13 +115,13 @@ mod tests { }, ..Default::default() } - .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A00".into()) + .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into()) .with_fee("12".into()) .with_sequence(100); assert_eq!( txn.mptoken_issuance_id.as_ref(), - "00000001A407AF5856CEFBF81F3D4A00" + "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58" ); assert!(txn.validate().is_ok()); } @@ -132,9 +134,24 @@ mod tests { transaction_type: TransactionType::MPTokenIssuanceDestroy, ..Default::default() }, - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), }; assert!(txn.validate().is_ok()); } + + #[test] + fn test_invalid_mptoken_issuance_id() { + let txn = MPTokenIssuanceDestroy { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceDestroy, + ..Default::default() + }, + // 32 hex chars; must be 48. + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + }; + + assert!(txn.validate().is_err()); + } } diff --git a/src/models/transactions/mptoken_issuance_set.rs b/src/models/transactions/mptoken_issuance_set.rs index 160230c0..35828545 100644 --- a/src/models/transactions/mptoken_issuance_set.rs +++ b/src/models/transactions/mptoken_issuance_set.rs @@ -7,6 +7,7 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_with::skip_serializing_none; use strum_macros::{AsRefStr, Display, EnumIter}; +use crate::core::addresscodec::decode_classic_address; use crate::models::{ transactions::{Transaction, TransactionType}, Model, ValidateCurrencies, XRPLModelException, XRPLModelResult, @@ -14,6 +15,10 @@ use crate::models::{ use super::{CommonFields, CommonTransactionBuilder}; +/// Expected length (in hex characters) of an MPTokenIssuanceID: +/// 24 bytes (Hash192) = 48 hex chars. +const MPTOKEN_ISSUANCE_ID_HEX_LEN: usize = 48; + /// Transactions of the MPTokenIssuanceSet type support additional values /// in the Flags field. /// @@ -90,6 +95,8 @@ pub struct MPTokenIssuanceSet<'a> { impl<'a> Model for MPTokenIssuanceSet<'a> { fn get_errors(&self) -> XRPLModelResult<()> { self._get_flag_error()?; + self._get_mptoken_issuance_id_error()?; + self._get_holder_error()?; self.validate_currencies() } } @@ -147,14 +154,58 @@ impl<'a> MPTokenIssuanceSet<'a> { let has_lock = self.has_flag(&MPTokenIssuanceSetFlag::TfMPTLock); let has_unlock = self.has_flag(&MPTokenIssuanceSetFlag::TfMPTUnlock); if has_lock && has_unlock { - Err(XRPLModelException::InvalidFlagCombination { + return Err(XRPLModelException::InvalidFlagCombination { flag1: "TfMPTLock".into(), flag2: "TfMPTUnlock".into(), - }) - } else { - Ok(()) + }); + } + // Rippled preflight requires exactly one of TfMPTLock / TfMPTUnlock + // (DomainID modification is another allowed form, not yet modelled + // here); reject the no-flag submission until that lands. + if !has_lock && !has_unlock { + return Err(XRPLModelException::ExpectedOneOf(&[ + "TfMPTLock", + "TfMPTUnlock", + ])); } + Ok(()) + } + + fn _get_mptoken_issuance_id_error(&self) -> XRPLModelResult<()> { + validate_mptoken_issuance_id(self.mptoken_issuance_id.as_ref()) + } + + fn _get_holder_error(&self) -> XRPLModelResult<()> { + if let Some(holder) = self.holder.as_deref() { + validate_holder_address(holder)?; + } + Ok(()) + } +} + +/// Validates that an `MPTokenIssuanceID` string is 48 ASCII hex characters +/// (24 bytes, Hash192 per XLS-33). +pub(crate) fn validate_mptoken_issuance_id(id: &str) -> XRPLModelResult<()> { + if id.len() != MPTOKEN_ISSUANCE_ID_HEX_LEN || !id.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(XRPLModelException::InvalidValueFormat { + field: "mptoken_issuance_id".into(), + format: alloc::format!("{MPTOKEN_ISSUANCE_ID_HEX_LEN}-char ASCII hex string"), + found: id.into(), + }); } + Ok(()) +} + +/// Validates that a `holder` string decodes as a classic XRPL address. +pub(crate) fn validate_holder_address(holder: &str) -> XRPLModelResult<()> { + if decode_classic_address(holder).is_err() { + return Err(XRPLModelException::InvalidValueFormat { + field: "holder".into(), + format: "classic XRPL address".into(), + found: holder.into(), + }); + } + Ok(()) } #[cfg(test)] @@ -175,7 +226,7 @@ mod tests { flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), ..Default::default() }, - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), holder: Some("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()), }; @@ -197,7 +248,7 @@ mod tests { .into(), ..Default::default() }, - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), ..Default::default() }; @@ -214,14 +265,14 @@ mod tests { }, ..Default::default() } - .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A00".into()) + .with_mptoken_issuance_id("00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into()) .with_holder("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()) .with_flag(MPTokenIssuanceSetFlag::TfMPTLock) .with_fee("12".into()); assert_eq!( txn.mptoken_issuance_id.as_ref(), - "00000001A407AF5856CEFBF81F3D4A00" + "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58" ); assert_eq!( txn.holder.as_deref(), @@ -232,21 +283,90 @@ mod tests { } #[test] - fn test_default() { + fn test_default_requires_flag() { + // With neither TfMPTLock nor TfMPTUnlock set, rippled rejects the tx + // in preflight. The model mirrors that (DomainID-only changes are not + // yet modelled). let txn = MPTokenIssuanceSet { common_fields: CommonFields { account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), transaction_type: TransactionType::MPTokenIssuanceSet, ..Default::default() }, - mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), ..Default::default() }; assert!(txn.holder.is_none()); + assert!(txn.validate().is_err()); + } + + #[test] + fn test_lock_only_is_ok() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), + ..Default::default() + }; + assert!(txn.validate().is_ok()); } + #[test] + fn test_invalid_mptoken_issuance_id_length() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), + ..Default::default() + }, + // 32 hex chars, invalid (must be 48). + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00".into(), + ..Default::default() + }; + + assert!(txn.validate().is_err()); + } + + #[test] + fn test_invalid_mptoken_issuance_id_non_hex() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), + ..Default::default() + }, + // Correct length, but contains a non-hex char ('Z'). + mptoken_issuance_id: "Z0000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), + ..Default::default() + }; + + assert!(txn.validate().is_err()); + } + + #[test] + fn test_invalid_holder_address() { + let txn = MPTokenIssuanceSet { + common_fields: CommonFields { + account: "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B".into(), + transaction_type: TransactionType::MPTokenIssuanceSet, + flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), + ..Default::default() + }, + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), + holder: Some("not_a_classic_address".into()), + }; + + assert!(txn.validate().is_err()); + } + #[test] fn test_flag_try_from_u32() { assert_eq!(