Skip to content

feat: add MPT (XLS-33) binary codec, transaction models, and integration tests#329

Open
e-desouza wants to merge 62 commits into
mainfrom
feat/mpt-binary-codec
Open

feat: add MPT (XLS-33) binary codec, transaction models, and integration tests#329
e-desouza wants to merge 62 commits into
mainfrom
feat/mpt-binary-codec

Conversation

@e-desouza

@e-desouza e-desouza commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

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

  • Binary codec: Hash192 type, MPT amount encode/decode
  • Amount/Currency: MPTAmount + MPTCurrency variants, custom Deserialize to prevent enum fallthrough, fixed-point from<f64>, i64::MAX bound enforcement, digit-only validation
  • Transaction models: MPTokenIssuanceCreate, MPTokenIssuanceDestroy, MPTokenIssuanceSet, MPTokenAuthorize, Clawback (with MPT + Holder field)
  • Ledger objects: MPToken, MPTokenIssuance with LockedAmount, non-null index validation
  • Requests: AccountObjectType::MptIssuance + Mptoken variants
  • Validation hardening: MaximumAmount range, AssetScale uint8 range, empty metadata rejection, asset_scale range
  • Integration tests: full MPT lifecycle end-to-end, 95%+ model coverage
  • txn_parser: preserve MPT balances in round trips
  • Addresses all PR Add binary codec and transaction models for MPT #131 review comments (6262aa0, 933b616)

How to validate

# Unit tests
cargo test --features std,json-rpc,helpers,cli,websocket --lib

# Integration tests (requires local xrpld standalone)
cargo test --release \
  --features std,json-rpc,helpers,cli,websocket,integration \
  --test integration_test \
  -- --test-threads=1

Related

Carries forward review from #131. Rebased onto main (includes #328).


Also closes #286 (wallet seed generation using non-cryptographic Hc128Rng instead of OsRng).

@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.92784% with 123 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.78%. Comparing base (7803c87) to head (cb3da08).

Files with missing lines Patch % Lines
src/models/transactions/clawback.rs 78.26% 55 Missing ⚠️
src/models/currency/mod.rs 76.81% 16 Missing ⚠️
src/models/amount/mod.rs 92.07% 13 Missing ⚠️
src/models/transactions/mptoken_issuance_create.rs 97.55% 11 Missing ⚠️
src/core/binarycodec/types/quality.rs 86.44% 8 Missing ⚠️
src/core/binarycodec/mod.rs 97.95% 6 Missing ⚠️
src/models/transactions/mptoken_authorize.rs 98.32% 3 Missing ⚠️
...rc/models/transactions/mptoken_issuance_destroy.rs 96.80% 3 Missing ⚠️
src/models/transactions/mptoken_issuance_set.rs 98.89% 3 Missing ⚠️
src/_serde/mod.rs 87.50% 2 Missing ⚠️
... and 3 more
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
integration 71.32% <ø> (ø)
unit 85.49% <94.92%> (+1.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/asynch/transaction/mod.rs 69.83% <ø> (ø)
src/core/binarycodec/types/hash.rs 97.67% <100.00%> (+0.12%) ⬆️
src/core/keypairs/mod.rs 88.77% <100.00%> (ø)
src/models/amount/mpt_amount.rs 100.00% <100.00%> (ø)
src/models/currency/mpt_currency.rs 100.00% <100.00%> (ø)
src/models/exceptions.rs 90.00% <ø> (ø)
src/models/ledger/objects/account_root.rs 96.42% <ø> (ø)
src/models/ledger/objects/mod.rs 84.09% <ø> (ø)
src/models/ledger/objects/mptoken.rs 100.00% <100.00%> (ø)
src/models/ledger/objects/mptoken_issuance.rs 100.00% <100.00%> (ø)
... and 17 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/models/transactions/clawback.rs
@e-desouza e-desouza marked this pull request as draft June 17, 2026 18:51
e-desouza added 27 commits June 18, 2026 12:04
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.
e-desouza added 19 commits June 18, 2026 12:04
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.
@e-desouza e-desouza force-pushed the feat/mpt-binary-codec branch from cda4449 to 9fa681e Compare June 18, 2026 16:05
…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.
@Patel-Raj11 Patel-Raj11 marked this pull request as ready for review June 18, 2026 18:12
Comment on lines +17 to +24
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>,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +43 to +51
// 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
)));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/core/keypairs/mod.rs
random_bytes = value;
} else {
let mut rng = rand_hc::Hc128Rng::from_entropy();
let mut rng = OsRng;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wallet seed generation uses Hc128Rng::from_entropy instead of OsRng

4 participants