Skip to content

Issue.from_value() and Issue.from_parser() produce inconsistent internal buffers for MPT issues #997

Description

@ckeshava

References

  • Upstream root cause: XRPLF/rippled#7035STIssue 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:

  1. test_from_value_and_from_parser_be_wire_buffers_match — fails, showing internal-storage divergence at .to_hex().
  2. test_from_parser_be_wire_round_trips_mpt_issuance_id — fails, showing the user-visible JSON corruption.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions