feat: add MPT (XLS-33) binary codec, transaction models, and integration tests#329
feat: add MPT (XLS-33) binary codec, transaction models, and integration tests#329e-desouza wants to merge 62 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #329 +/- ##
==========================================
+ Coverage 83.59% 84.78% +1.19%
==========================================
Files 220 229 +9
Lines 22237 24645 +2408
==========================================
+ Hits 18589 20896 +2307
- Misses 3648 3749 +101
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
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.
Add MPTokenAuthorize, MPTokenIssuanceCreate, MPTokenIssuanceDestroy, and MPTokenIssuanceSet to the TransactionType enum for use by MPT transaction models.
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.
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.
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.
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.
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.
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
…ture 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
…uilds - 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)
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.
…gration 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.
…e) 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<Vec<T>> impl instead.
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.
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.
- 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.
- Add asset_scale range validation (0-19) per XLS-89 spec - Add transfer_fee requires TfMPTCanTransfer flag validation - Add mptoken_metadata max byte length validation (1024 bytes per XLS-89) - Remove unnecessary validate_currencies() call from MPTokenAuthorize - Add MPTokenIssuanceFlag and MPTokenIssuanceMutableFlag enums to MPTokenIssuance ledger object - Add mutable_flags field to MPTokenIssuance struct (sfMutableFlags, SoeDefault) - Fix MPToken and MPTokenIssuance tests to use realistic ledger_index values - Add Hash192 encode/decode round-trip test in binary codec - Fix encode test flags assertion after adding TfMPTCanTransfer requirement - Configure cargo to use all available CPU cores for parallel builds
- Add hex-content and even-length validation to _get_metadata_error; plain length division passed odd-length and non-hex strings silently - Add tests for odd-length, non-hex, and transfer_fee=Some(0) edge cases - Fix TfMPTCanEscrow doc comment (was duplicating TfMPTCanTransfer text) - Add opt_lgr_obj_flags serde module for Option<FlagCollection<F>> fields - Annotate mutable_flags with opt_lgr_obj_flags so it round-trips as an integer in the rippled JSON format rather than a JSON array - Assert integer format in test_serde for MPTokenIssuance
…d MPTokenIssuance Both ledger objects now return XRPLModelException::MissingField when both index and ledger_index are absent, matching the invariant enforced by rippled (every ledger object must have a locatable key). Adds test_missing_index_and_ledger_index_error to each module.
Add TryFrom<u32> coverage for all MPTokenIssuanceFlag and MPTokenIssuanceMutableFlag variants, Transaction trait method coverage (get_transaction_type, get_common_fields, get_mut_common_fields), and with_flags builder coverage across all MPT transaction types.
"Funding request timed out" contains "timed out" not "timeout", so it was hitting the Failed branch instead of Skipped when the faucet is unreachable. Add explicit "timed out" and "faucet" patterns so these tests skip gracefully in offline environments.
- Add XRPL_WS_URL env var to test_autofill_txn so it can be pointed at a local standalone node (ws://localhost:6006/). - Use the genesis account when running against localhost. - Wrap autofill errors through the known-network-error check so result-type mismatches from WS push messages skip cleanly. - Add "Unexpected result type", "timed out", "faucet" to the known network error patterns so faucet-dependent tests skip gracefully when no faucet is reachable.
Implement custom Debug for Wallet that redacts all sensitive key material (seed, public_key, private_key) while preserving classic_address and sequence for minimal debugging information.
Replace arithmetic sum with bitwise OR operation when combining flag bits during serialization. Flags should be combined with bitwise operations, not arithmetic addition.
Use OS-provided cryptographic randomness (OsRng) instead of the seeded HC-128 algorithm for generating wallet seeds. OsRng provides higher security guarantees for cryptographic key derivation.
Add missing AccountRootFlag enum variant for allowing trustline clawback on tokens issued by the account. This flag enables issuers to claw back tokens they have issued from holders' trust lines.
Switch the default XRPL_WS_URL from the xrpl-labs testnet to wss://s.altnet.rippletest.net:51233/ which is the WebSocket endpoint for the official Ripple altnet (consistent with xrpl.js and other SDKs). Port 51234 is JSON-RPC; port 51233 is WebSocket. Remove the is_known_network_error skip that silently swallowed autofill errors — genuine autofill failures should propagate as test failures. The connection-open skip is retained so the test no-ops gracefully when the network or node is unreachable.
The file contained only `[build] jobs = 12` which is not portable across dev machines with fewer cores. Remove it from the tree and add it to .gitignore so per-machine overrides continue to work without polluting the repository.
Adds MPT (Multi-Purpose Token) support to the model Amount and Currency enums. The codec layer already serialised MPT amounts; these variants let callers construct and validate MPT amounts at the model layer. MPTAmount (src/models/amount/mpt_amount.rs): - Fields: value (u64 string) + mpt_issuance_id (48-char hex / Hash192) - Model::get_errors validates both fields - Deserialized before IssuedCurrencyAmount by detecting mpt_issuance_id key - is_mpt() helper added; is_issued_currency() narrowed to only the ICA variant - From<MPTAmount> for Amount added MPTCurrency (src/models/currency/mpt_currency.rs): - Field: mpt_issuance_id (48-char hex) - Model::get_errors validates the field - Deserialized before IssuedCurrency/XRP in Currency enum - From<MPTCurrency> for Currency added Blast-radius match sites updated: - clawback.rs: MPTAmount arm rejects (Clawback is ICA-only) - xchain_claim.rs: MPTAmount arm returns AmountMismatch (bridges are XRP/ICA-only) - txn_parser/utils/mod.rs: MPTAmount maps to Balance using mpt_issuance_id as currency
The function previously asserted the provisional engine_result from sign_and_submit, then called ledger_accept() once and returned. This meant callers could proceed before the transaction was in a validated ledger. Now test_transaction snapshots the validated ledger close_time before submission, calls ledger_accept() to close the in-progress ledger, and then waits for the close_time to advance (via wait_for_ledger_close_time) before returning. This ensures the transaction is definitively in validated state when the caller continues.
rippled rejects MaximumAmount == 0 and MaximumAmount > 2^63-1 (i64::MAX) in preflight. Add _get_maximum_amount_error which parses the field as u64 and enforces both bounds. Tests cover: zero rejected, above-max rejected, exact max accepted, None (omitted) accepted.
LockedAmount tracks the amount of MPT currently locked in escrow or by other mechanisms. Present in xrpl.js MPToken and MPTokenIssuance interfaces but missing from xrpl-rust. Field is optional (null when the TokenEscrow amendment is not active).
xrpl.js validation: !isHex(metadata) which returns false for an empty string, making "" invalid. xrpl-rust's len() % 2 check passed for "". Add an explicit is_empty() guard so the error message now reads "non-empty even-length ASCII hex string".
Per xrpl.js ClawbackAmount = IssuedCurrencyAmount | MPTAmount (not XRP). xrpl-rust was incorrectly rejecting MPTAmount. Changes: - Add optional Holder field (required when amount is MPTAmount) - Allow Amount::MPTAmount — no longer rejected - Validate MPT clawback: Holder required, Holder != Account - Validate ICA clawback: Holder must be absent - Four new tests covering MPT happy path, missing Holder, Holder == Account, and ICA-with-Holder error
xrpl.js AccountObjectType derives from LedgerEntryFilter which includes 'mpt_issuance' and 'mptoken'. Add the same variants so callers can filter account_objects responses by MPT object type.
XLS-33 and rippled limit MPT amounts to i64::MAX (9223372036854775807), not u64::MAX. Two fixes: 1. TryInto<BigDecimal> for Amount::MPTAmount: after parsing as u64, add an explicit > i64::MAX check before converting to BigDecimal. 2. MPTAmount::get_errors(): same i64::MAX bound check after parse::<u64> so model-layer validation is consistent with the codec layer. Also fix the misleading comment that claimed u64::MAX == 9223372036854775807.
MPTAmount value range and mpt_issuance_id format checks were skipped for Clawback transactions. Delegate to self.amount.get_errors() so MPTAmount and IssuedCurrencyAmount validation runs consistently with all other transaction types.
cda4449 to
9fa681e
Compare
…r entries Add byte-for-byte encode/decode tests for MPTokenIssuance and MPToken ledger objects using authoritative vectors from xrpl.js ripple-binary-codec/test/uint.test.ts. Each test decodes the reference binary, asserts all field values, then re-encodes and compares the output hex exactly against the source vector. Addresses PR #131 reviewer request (ckeshava) for exact-match codec tests from a reference implementation rather than substring assertions.
…ures Add ACCOUNT_GENESIS, ACCOUNT_ISSUER, ACCOUNT_HOLDER, ACCOUNT_HOLDER_2, and ACCOUNT_ALT to test_constants in utils/testing.rs. Replace all hardcoded address literals across MPT transaction, ledger object, request, and binary codec tests.
…_error Replace hand-rolled inline pattern list with the shared is_known_network_error helper. Semantic XRPL errors (actNotFound, malformedTx, etc.) were already propagating correctly — this just removes the duplicate pattern list that diverged from COMMON_NETWORK_ERRORS.
| pub struct MPTAmount<'a> { | ||
| /// The token quantity, expressed as a non-negative integer string. | ||
| pub value: Cow<'a, str>, | ||
| /// The MPTokenIssuanceID that identifies which MPT this amount belongs to. | ||
| /// Must be a 48-character ASCII hex string (24 bytes, Hash192). | ||
| pub mpt_issuance_id: Cow<'a, str>, | ||
| } | ||
|
|
There was a problem hiding this comment.
Can you add an integration test that validates the Payment transaction flow between two authorized accounts? In the existing set up, we are not validating the correct serialization / de-serialization of the MPTAmount into the wire format.
Such a test will cover this whitespace.
| // Bounds check: exponent must be in representable range [-100, 155] | ||
| // Prevent overflow when computing scale and creating BigDecimal | ||
| if !(-100..=155).contains(&exponent) { | ||
| return Err(XRPLCoreException::XRPLUtilsError(alloc::format!( | ||
| "Quality exponent {} out of representable range [-100, 155]", | ||
| exponent | ||
| ))); | ||
| } | ||
|
|
There was a problem hiding this comment.
Can you remove this change from this PR? This appears to be a general bug-fix seperate from the MPTokensV1 work.
Let me know if the quality changes were inspired from MPT work.
| random_bytes = value; | ||
| } else { | ||
| let mut rng = rand_hc::Hc128Rng::from_entropy(); | ||
| let mut rng = OsRng; |
There was a problem hiding this comment.
I believe this closes an open issue in the xrpl-rust repo. However, the current PR description is missing the appropriate issue number, can you add the bug number to the Changelog+PR description?
| #[skip_serializing_none] | ||
| #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)] | ||
| #[serde(rename_all = "PascalCase")] | ||
| pub struct Clawback<'a> { |
There was a problem hiding this comment.
I could not find integration tests related to this Clawback transaction. Have I missed it amongst the files?
I'm looking for an example where the transaction is sent to rippled node and we get back a transaction-success result.
| pub previous_txn_lgr_seq: u32, | ||
| /// Bitmask of which fields the issuer may mutate after creation. | ||
| /// Stored as `sfMutableFlags` on-ledger. | ||
| #[serde(with = "opt_lgr_obj_flags", skip_serializing_if = "Option::is_none")] |
There was a problem hiding this comment.
Issue found by AI:
serde's implicit "missing field → None" for Option does not apply when deserialize_with (i.e. with) is set unless #[serde(default)] is also present. So deserializing any MPTokenIssuance JSON that omits MutableFlags — which is the common case on-ledger — will error with missing field "MutableFlags". The existing test_serde always sets Some(...), so this gap is untested. (Note opt_lgr_obj_flags::deserialize also always maps to Some, so None is effectively unreachable anyway.)
Fix: add #[serde(default)]:
#[serde(default, with = "opt_lgr_obj_flags", skip_serializing_if = "Option::is_none")]
and add a regression test that deserializes an MPTokenIssuance JSON with no MutableFlags key.
Why
Implements full XLS-33 (Multi-Purpose Tokens) support in xrpl-rust. This carries forward and extends the work reviewed in #131, addressing all review comments and adding hardening fixes identified during testing.
What changed
Hash192type, MPT amount encode/decodeMPTAmount+MPTCurrencyvariants, customDeserializeto prevent enum fallthrough, fixed-pointfrom<f64>,i64::MAXbound enforcement, digit-only validationMPTokenIssuanceCreate,MPTokenIssuanceDestroy,MPTokenIssuanceSet,MPTokenAuthorize,Clawback(with MPT +Holderfield)MPToken,MPTokenIssuancewithLockedAmount, non-null index validationAccountObjectType::MptIssuance+MptokenvariantsMaximumAmountrange,AssetScaleuint8 range, empty metadata rejection,asset_scalerange6262aa0,933b616)How to validate
Related
Carries forward review from #131. Rebased onto
main(includes #328).Also closes #286 (wallet seed generation using non-cryptographic Hc128Rng instead of OsRng).