References
- Upstream root cause: XRPLF/rippled#7035 —
STIssue MPT serialization disagrees with makeMptID. rippled stores MPTIDs as canonical big-endian (via makeMptID's boost::endian::native_to_big), but STIssue::add() / STIssue::STIssue(SerialIter&, …) route the sequence through add32/get32, which on little-endian hosts produces a host-endian (effectively LE) wire encoding. The fix proposed there is amendment-gated (fixMPTIssueSerialization) and changes the wire format to canonical BE.
- Sibling client-side report: XRPLF/xrpl.js#3332 — the same three-way inconsistency in
ripple-binary-codec's Issue codec (from() / fromParser() / toJSON()).
- This issue: the xrpl-py counterpart in
xrpl/core/binarycodec/types/issue.py.
Summary
xrpl-py's Issue codec for MPT issues has the same structural bug that xrpl.js#3332 reports for ripple-binary-codec: its three MPT code paths (from_value(), from_parser(), to_json()) disagree on the byte order of the trailing 4-byte sequence in the internal buffer.
The codec round-trips correctly today only because rippled's wire output is itself byte-swapped (per rippled#7035), and from_value() / to_json() were written with a compensating byte-swap to match what rippled emits — see the in-source comment at issue.py:67-69:
"sequence number is stored in little-endian format, however it is interpreted in big-endian format. Read Indexes.cpp:makeMptID method for more details."
from_parser(), however, stores the wire bytes verbatim with no swap. The three paths therefore agree only when the wire blob already has LE sequence bytes (which is what rippled emits today). A hand-crafted blob with canonical BE sequence bytes round-trips through from_parser() → to_json() with a byte-reversed mpt_issuance_id. The same divergence will activate broadly if fixMPTIssueSerialization (rippled#7035) is amended in and rippled begins emitting canonical BE sequence bytes.
Affected file
xrpl/core/binarycodec/types/issue.py
Reproduction
from xrpl.core.binarycodec.types.issue import Issue
from xrpl.core.binarycodec.binary_wrappers.binary_parser import BinaryParser
# 24-byte MPTID with sequence 0x00010203 and a 20-byte issuer.
mpt_issuance_id = "00010203E0739D43718DB5815CE070D4D514A261EC872C93"
# Wire layout per the current Issue codec for an MPT:
# issuer (20) || NO_ACCOUNT sentinel (20) || sequence (4)
# Built here with the sequence in big-endian (matching the byte order of
# mpt_issuance_id itself, i.e. the canonical encoding makeMptID produces).
wire_seq_be = (
"E0739D43718DB5815CE070D4D514A261EC872C93" # issuer
"0000000000000000000000000000000000000001" # NO_ACCOUNT sentinel
"00010203" # seq BE
)
from_json = Issue.from_value({"mpt_issuance_id": mpt_issuance_id})
from_wire = Issue.from_parser(BinaryParser(wire_seq_be))
print(from_json.to_hex().upper())
# E0739D43...0000000000000000000000000000000000000001 03020100 <- seq stored LE
print(from_wire.to_hex().upper())
# E0739D43...0000000000000000000000000000000000000001 00010203 <- seq stored BE (verbatim from wire)
print(from_wire.to_json())
# {'mpt_issuance_id': '03020100E0739D43718DB5815CE070D4D514A261EC872C93'}
# ^^^^^^^^ sequence is byte-reversed relative to the input mpt_issuance_id.
Root cause
The internal 44-byte buffer for an MPT issue is issuer(20) || NO_ACCOUNT(20) || seq(4). The three code paths disagree on the byte order of the trailing 4 sequence bytes:
Issue.from_value() (issue.py:72-96, specifically lines 81-88): reads the JSON sequence with int.from_bytes(..., byteorder="little") and re-encodes with .to_bytes(4, byteorder="big") — net effect is a byte-swap, so storage is byte-reversed relative to the JSON input. The local variable is named sequenceBE but the stored bytes are LE relative to JSON. This is a deliberate compensation for rippled's host-endian wire output (rippled#7035).
Issue.from_parser() (issue.py:103-134, specifically lines 124-132): reads 4 bytes from the wire as a UInt32 and stores them verbatim, no endian conversion. Does not apply the compensating byte-swap.
Issue.to_json() (issue.py:136-179, specifically lines 156-165): reads the trailing 4 bytes unconditionally as byteorder="little" and re-encodes as BE — the inverse of from_value. Only correct if storage is the byte-reversed (LE) form from_value produces.
So from_value and to_json form a self-consistent pair under "storage = byte-reversed JSON," and they happen to round-trip with rippled's current output because rippled also emits byte-reversed bytes (for an unrelated reason — see #7035). from_parser is the odd one out: it stores wire bytes verbatim, so it only round-trips if the wire bytes are already byte-reversed. A canonical BE-encoded wire blob breaks the chain.
Demonstrating tests
Branch ckeshava/xrpl-py:issue/mpt-issue-fromparser-discrepancy adds three @unittest.expectedFailure tests in tests/unit/core/binarycodec/types/test_issue.py:
test_from_value_and_from_parser_be_wire_buffers_match — fails, showing internal-storage divergence at .to_hex().
test_from_parser_be_wire_round_trips_mpt_issuance_id — fails, showing the user-visible JSON corruption.
test_to_json_consistent_across_construction_paths — fails, showing two construction paths for the same logical MPT issue emit different JSON.
Sample diff from test #2 (sequence 0x00010203 is non-palindromic so the corruption is visible byte-for-byte):
- "mpt_issuance_id": "00010203E0739D43718DB5815CE070D4D514A261EC872C93" (expected)
+ "mpt_issuance_id": "03020100E0739D43718DB5815CE070D4D514A261EC872C93" (actual)
Run:
poetry run python -m unittest tests.unit.core.binarycodec.types.test_issue -v
# ... OK (expected failures=3)
Removing the @unittest.expectedFailure decorators (or running pytest with --runxfail) surfaces the assertion diffs.
Suggested fix
Pick one canonical internal storage byte order and make all three paths agree. Two consistent options:
- Store BE (matches the JSON byte order, matches the canonical encoding
makeMptID produces, matches what the wire format will be after fixMPTIssueSerialization lands): write JSON bytes verbatim in from_value; keep from_parser verbatim if the wire is BE; flip to_json to read as BE int. This aligns with the long-term direction set by rippled#7035.
- Store LE (matches today's rippled wire): keep
from_value as-is; have from_parser byte-swap the trailing 4 bytes on read; keep to_json as-is. This is a shorter-term fix that keeps current wire compatibility but will need to flip when fixMPTIssueSerialization activates.
Coordination with xrpl.js#3332 and rippled#7035 is desirable so that all three projects converge on the same canonical storage and wire byte order.
References
STIssueMPT serialization disagrees withmakeMptID. rippled stores MPTIDs as canonical big-endian (viamakeMptID'sboost::endian::native_to_big), butSTIssue::add()/STIssue::STIssue(SerialIter&, …)route the sequence throughadd32/get32, which on little-endian hosts produces a host-endian (effectively LE) wire encoding. The fix proposed there is amendment-gated (fixMPTIssueSerialization) and changes the wire format to canonical BE.ripple-binary-codec'sIssuecodec (from()/fromParser()/toJSON()).xrpl/core/binarycodec/types/issue.py.Summary
xrpl-py's
Issuecodec for MPT issues has the same structural bug that xrpl.js#3332 reports forripple-binary-codec: its three MPT code paths (from_value(),from_parser(),to_json()) disagree on the byte order of the trailing 4-byte sequence in the internal buffer.The codec round-trips correctly today only because rippled's wire output is itself byte-swapped (per rippled#7035), and
from_value()/to_json()were written with a compensating byte-swap to match what rippled emits — see the in-source comment atissue.py:67-69:from_parser(), however, stores the wire bytes verbatim with no swap. The three paths therefore agree only when the wire blob already has LE sequence bytes (which is what rippled emits today). A hand-crafted blob with canonical BE sequence bytes round-trips throughfrom_parser()→to_json()with a byte-reversedmpt_issuance_id. The same divergence will activate broadly iffixMPTIssueSerialization(rippled#7035) is amended in and rippled begins emitting canonical BE sequence bytes.Affected file
xrpl/core/binarycodec/types/issue.pyReproduction
Root cause
The internal 44-byte buffer for an MPT issue is
issuer(20) || NO_ACCOUNT(20) || seq(4). The three code paths disagree on the byte order of the trailing 4 sequence bytes:Issue.from_value()(issue.py:72-96, specifically lines 81-88): reads the JSON sequence withint.from_bytes(..., byteorder="little")and re-encodes with.to_bytes(4, byteorder="big")— net effect is a byte-swap, so storage is byte-reversed relative to the JSON input. The local variable is namedsequenceBEbut the stored bytes are LE relative to JSON. This is a deliberate compensation for rippled's host-endian wire output (rippled#7035).Issue.from_parser()(issue.py:103-134, specifically lines 124-132): reads 4 bytes from the wire as aUInt32and stores them verbatim, no endian conversion. Does not apply the compensating byte-swap.Issue.to_json()(issue.py:136-179, specifically lines 156-165): reads the trailing 4 bytes unconditionally asbyteorder="little"and re-encodes as BE — the inverse offrom_value. Only correct if storage is the byte-reversed (LE) formfrom_valueproduces.So
from_valueandto_jsonform a self-consistent pair under "storage = byte-reversed JSON," and they happen to round-trip with rippled's current output because rippled also emits byte-reversed bytes (for an unrelated reason — see #7035).from_parseris the odd one out: it stores wire bytes verbatim, so it only round-trips if the wire bytes are already byte-reversed. A canonical BE-encoded wire blob breaks the chain.Demonstrating tests
Branch
ckeshava/xrpl-py:issue/mpt-issue-fromparser-discrepancyadds three@unittest.expectedFailuretests intests/unit/core/binarycodec/types/test_issue.py:test_from_value_and_from_parser_be_wire_buffers_match— fails, showing internal-storage divergence at.to_hex().test_from_parser_be_wire_round_trips_mpt_issuance_id— fails, showing the user-visible JSON corruption.test_to_json_consistent_across_construction_paths— fails, showing two construction paths for the same logical MPT issue emit different JSON.Sample diff from test #2 (sequence
0x00010203is non-palindromic so the corruption is visible byte-for-byte):Run:
poetry run python -m unittest tests.unit.core.binarycodec.types.test_issue -v # ... OK (expected failures=3)Removing the
@unittest.expectedFailuredecorators (or running pytest with--runxfail) surfaces the assertion diffs.Suggested fix
Pick one canonical internal storage byte order and make all three paths agree. Two consistent options:
makeMptIDproduces, matches what the wire format will be afterfixMPTIssueSerializationlands): write JSON bytes verbatim infrom_value; keepfrom_parserverbatim if the wire is BE; flipto_jsonto read as BE int. This aligns with the long-term direction set by rippled#7035.from_valueas-is; havefrom_parserbyte-swap the trailing 4 bytes on read; keepto_jsonas-is. This is a shorter-term fix that keeps current wire compatibility but will need to flip whenfixMPTIssueSerializationactivates.Coordination with xrpl.js#3332 and rippled#7035 is desirable so that all three projects converge on the same canonical storage and wire byte order.