diff --git a/.ci-config/rippled.cfg b/.ci-config/xrpld.cfg similarity index 98% rename from .ci-config/rippled.cfg rename to .ci-config/xrpld.cfg index ce092a15..7ba05ab9 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/xrpld.cfg @@ -43,7 +43,7 @@ small [node_db] type=NuDB -path=/var/lib/rippled/db/nudb +path=/var/lib/xrpld/db/nudb advisory_delete=0 # How many ledgers do we want to keep (history)? @@ -58,10 +58,10 @@ online_delete=256 256 [database_path] -/var/lib/rippled/db +/var/lib/xrpld/db [debug_logfile] -/var/log/rippled/debug.log +/var/log/xrpld/debug.log [network_id] 0 diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 12e3df17..9f97ca3b 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -11,7 +11,9 @@ on: name: Integration Test env: - RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop + # rippled binary was renamed to xrpld; use the new image name. + # Tracks xrpl.js PR #3270 (https://github.com/XRPLF/xrpl.js/pull/3270). + XRPLD_DOCKER_IMAGE: rippleci/xrpld:develop jobs: integration_test: @@ -22,29 +24,37 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Start rippled standalone + - name: Run xrpld in standalone mode run: | - docker run --detach --rm \ - -p 5005:5005 \ - -p 6006:6006 \ - --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" \ - --name rippled-service \ - --health-cmd="rippled server_info || exit 1" \ - --health-interval=5s \ - --health-retries=10 \ - --health-timeout=2s \ - --env GITHUB_ACTIONS=true \ - --env CI=true \ - --entrypoint bash \ - ${{ env.RIPPLED_DOCKER_IMAGE }} \ - -c "mkdir -p /var/lib/rippled/db/ && rippled -a" + docker run \ + --detach \ + --rm \ + --publish 5005:5005 \ + --publish 6006:6006 \ + --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/xrpld/" \ + --name xrpld-service \ + ${{ env.XRPLD_DOCKER_IMAGE }} --standalone - - name: Wait for rippled to be healthy + - name: Wait for xrpld to accept RPC 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=xrpld-service | grep -q .; then + echo "Container exited unexpectedly" + docker logs xrpld-service 2>&1 || true + exit 1 + fi + if curl -fsS -o /dev/null -X POST http://localhost:5005/ \ + -H 'Content-Type: application/json' \ + -d '{"method":"server_info","params":[{}]}'; then + echo "xrpld RPC ready (attempt $i)" + exit 0 + fi + echo "Attempt $i/30: RPC not ready yet" sleep 2 done + echo "Timed out waiting for xrpld RPC" + docker logs xrpld-service 2>&1 || true + exit 1 - uses: dtolnay/rust-toolchain@stable @@ -64,6 +74,8 @@ jobs: env: RUST_BACKTRACE: 1 - - name: Stop rippled + - name: Dump xrpld logs and stop container if: always() - run: docker stop rippled-service + run: | + docker logs xrpld-service || true + docker stop xrpld-service || true 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 91801fc8..a6107209 100644 --- a/src/core/binarycodec/mod.rs +++ b/src/core/binarycodec/mod.rs @@ -175,6 +175,8 @@ pub fn decode_ledger_data(hex_string: &str) -> XRPLCoreResult { #[cfg(all(test, feature = "std"))] mod test { + use alloc::vec; + use super::*; #[path = "binary_json_tests.rs"] @@ -185,4 +187,398 @@ mod test { mod tx_encode_decode_tests; #[path = "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"); + } } 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()); } 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/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..d268f428 --- /dev/null +++ b/src/models/ledger/objects/mptoken.rs @@ -0,0 +1,122 @@ +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}; + +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. +/// +/// `` +#[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, MPTokenFlag>, + /// The owner (holder) of these MPTs. + pub account: Cow<'a, str>, + /// The `MPTokenIssuance` identifier. + #[serde(rename = "MPTokenIssuanceID")] + pub mptoken_issuance_id: Cow<'a, str>, + /// 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>, + /// The identifying hash of the transaction that most recently modified + /// this entry. + #[serde(rename = "PreviousTxnID")] + pub previous_txn_id: Cow<'a, str>, + /// The index of the ledger that contains the transaction that most + /// recently modified this object. + pub previous_txn_lgr_seq: u32, + /// 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>, +} + +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![MPTokenFlag::LsfMPTAuthorized]), + ledger_entry_type: LedgerEntryType::MPToken, + index: Some(Cow::from( + "BFA9BE27383FA315651E26FDE1FA30815C5A5D0544EE10EC33D3E92532993769", + )), + ledger_index: None, + }, + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".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: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".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..004ba19e --- /dev/null +++ b/src/models/ledger/objects/mptoken_issuance.rs @@ -0,0 +1,185 @@ +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}; + +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. +/// +/// `` +#[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, MPTokenIssuanceFlag>, + /// The address of the account that controls both the issuance amounts + /// and characteristics of a particular fungible token. + pub issuer: Cow<'a, str>, + /// 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 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 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>, + /// 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 about this issuance, in hex format. The limit is + /// 1024 bytes. + #[serde(rename = "MPTokenMetadata")] + pub mptoken_metadata: Option>, + /// The Sequence (or Ticket) number of the transaction that created this + /// issuance, helping uniquely identify it. + pub sequence: u32, + /// A hint indicating which page of the owner directory links to this + /// entry. + pub owner_node: Option>, + /// The identifying hash of the transaction that most recently modified + /// this entry. + #[serde(rename = "PreviousTxnID")] + pub previous_txn_id: Cow<'a, str>, + /// The index of the ledger that contains the transaction that most + /// recently 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![MPTokenIssuanceFlag::LsfMPTCanTransfer]), + 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()); + } +} diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..0e1a2ccc 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -15,6 +15,10 @@ 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 nftoken_accept_offer; pub mod nftoken_burn; pub mod nftoken_cancel_offer; @@ -80,6 +84,10 @@ pub enum TransactionType { EscrowCancel, EscrowCreate, EscrowFinish, + MPTokenAuthorize, + MPTokenIssuanceCreate, + MPTokenIssuanceDestroy, + MPTokenIssuanceSet, NFTokenAcceptOffer, NFTokenBurn, NFTokenCancelOffer, diff --git a/src/models/transactions/mptoken_authorize.rs b/src/models/transactions/mptoken_authorize.rs new file mode 100644 index 00000000..f9157145 --- /dev/null +++ b/src/models/transactions/mptoken_authorize.rs @@ -0,0 +1,281 @@ +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::mptoken_issuance_set::{validate_holder_address, validate_mptoken_issuance_id}; +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<()> { + 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() + } +} + +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 alloc::vec; + + 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: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".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: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".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("00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into()) + .with_holder("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()) + .with_flag(MPTokenAuthorizeFlag::TfMPTUnauthorize) + .with_fee("12".into()); + + assert_eq!( + txn.mptoken_issuance_id.as_ref(), + "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58" + ); + 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: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into(), + ..Default::default() + }; + + assert!(txn.has_flag(&MPTokenAuthorizeFlag::TfMPTUnauthorize)); + 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!( + 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()); + } +} diff --git a/src/models/transactions/mptoken_issuance_create.rs b/src/models/transactions/mptoken_issuance_create.rs new file mode 100644 index 00000000..2bd888a7 --- /dev/null +++ b/src/models/transactions/mptoken_issuance_create.rs @@ -0,0 +1,431 @@ +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; + +/// 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. +/// +/// 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._get_asset_scale_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(()) + } + } + + 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)] +mod tests { + use alloc::{string::ToString, vec}; + + 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_asset_scale_error() { + let txn = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: "rU4EE1FskCPJw5QkLx1iGgdWiJa6HeqYyb".into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + ..Default::default() + }, + 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 19, found 20)" + ); + } + + #[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(19), + ..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!( + 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); + } +} diff --git a/src/models/transactions/mptoken_issuance_destroy.rs b/src/models/transactions/mptoken_issuance_destroy.rs new file mode 100644 index 00000000..bf48ca47 --- /dev/null +++ b/src/models/transactions/mptoken_issuance_destroy.rs @@ -0,0 +1,157 @@ +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::mptoken_issuance_set::validate_mptoken_issuance_id; +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<()> { + validate_mptoken_issuance_id(self.mptoken_issuance_id.as_ref())?; + 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: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".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("00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into()) + .with_fee("12".into()) + .with_sequence(100); + + assert_eq!( + txn.mptoken_issuance_id.as_ref(), + "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58" + ); + 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: "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 new file mode 100644 index 00000000..35828545 --- /dev/null +++ b/src/models/transactions/mptoken_issuance_set.rs @@ -0,0 +1,393 @@ +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::core::addresscodec::decode_classic_address; +use crate::models::{ + transactions::{Transaction, TransactionType}, + Model, ValidateCurrencies, XRPLModelException, XRPLModelResult, +}; + +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. +/// +/// 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._get_mptoken_issuance_id_error()?; + self._get_holder_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 { + return Err(XRPLModelException::InvalidFlagCombination { + flag1: "TfMPTLock".into(), + flag2: "TfMPTUnlock".into(), + }); + } + // 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)] +mod tests { + use alloc::vec; + + 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: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".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: "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".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("00000001A407AF5856CEFBF81F3D4A0000000000A407AF58".into()) + .with_holder("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into()) + .with_flag(MPTokenIssuanceSetFlag::TfMPTLock) + .with_fee("12".into()); + + assert_eq!( + txn.mptoken_issuance_id.as_ref(), + "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58" + ); + assert_eq!( + txn.holder.as_deref(), + Some("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh") + ); + assert!(txn.has_flag(&MPTokenIssuanceSetFlag::TfMPTLock)); + assert!(txn.validate().is_ok()); + } + + #[test] + 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: "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!( + 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()); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 14093264..a1c06905 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -277,3 +277,47 @@ 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, MPTokenIssuanceCreateFlag}, + CommonFields, TransactionType, + }; + + let mut tx = MPTokenIssuanceCreate { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::MPTokenIssuanceCreate, + flags: vec![MPTokenIssuanceCreateFlag::TfMPTCanLock].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..70998f6a --- /dev/null +++ b/tests/transactions/mptoken_authorize.rs @@ -0,0 +1,37 @@ +// 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, + ..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..a6e07d76 --- /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, MPTokenIssuanceCreateFlag}, + 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, + ..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, + flags: vec![MPTokenIssuanceCreateFlag::TfMPTCanTransfer].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..7c41bb73 --- /dev/null +++ b/tests/transactions/mptoken_issuance_destroy.rs @@ -0,0 +1,35 @@ +// 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, + ..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..0bec1b12 --- /dev/null +++ b/tests/transactions/mptoken_issuance_set.rs @@ -0,0 +1,38 @@ +// 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, +}; + +#[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, + flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(), + ..Default::default() + }, + mptoken_issuance_id: issuance_id.into(), + holder: None, // lock the entire issuance + }; + + test_transaction(&mut tx, &issuer).await; + }) + .await; +}