Skip to content

Extend byte equality to MPMA, and fix memo transport the API ignored - #216

Merged
droplister merged 1 commit into
mainfrom
harden/byte-equality-mpma
Aug 3, 2026
Merged

Extend byte equality to MPMA, and fix memo transport the API ignored#216
droplister merged 1 commit into
mainfrom
harden/byte-equality-mpma

Conversation

@droplister

Copy link
Copy Markdown
Member

Continues the verification architecture (#214, #215): MPMA moves from field-by-field comparison to whole-message byte equality — and fixing the packer surfaced a real compose bug.

The bug: MPMA memos never reached the API

composeMPMA sent per-send memos as memos[]= query params. Core's query_params() builds lists from repeated plain keys (request.args.to_dict(flat=False)) and does nothing with a PHP-style [] suffix — memos[] is a different, ignored parameter. So compose silently proceeded memo-less: the user typed memos, the transaction composed without them. Fixed by sending repeated memos= keys, verified against a live node by a new oracle case that composes with per-send memos and requires our bytes (which include them) to match core's.

Two related transport fixes:

  • A memo shared by every destination (the send form's multi-destination flow) now travels once as the whole-send memo param instead of a per-send list saying the same thing n times.
  • Mixed hex/text memo lists are refused with a clear error: core applies a single memos_are_hex flag to the whole list, so the mix is not expressible — an error beats composing memos that mean something else.

The packer

MPMA (id 3) per core's mpmaencoding.py: a >H-counted LUT of legacy 21-byte packed addresses sorted lexicographically, then a bit stream — whole-send memo, and per asset (sorted by name) a continuation bit, 64-bit asset id, nbits-wide count−1, and each send's nbits-wide LUT index, 64-bit quantity, and memo bits — terminated by a 0 bit, zero-padded to a byte.

The subtle finding: one distinct destination makes nbits zero, and the count/index fields occupy no bits at all. Core's pinned bitstring (4.1.4) appends nothing for uint:0; newer versions raise, which made the shape look uncomposable until the fixture generator ran under the pin — same version lesson as cbor2 in #215. This matters because several-assets-to-one-address is the dominant real MPMA traffic (all five most recent on-chain samples).

Both wallet flows byte-verify:

  • the dedicated MPMA page — its quantity normalization moves from the page's compose method into normalizeFormData, so verification sees the same base units the API receives;
  • the send form's comma-separated destinations, packed as the equivalent MPMA message.

Declined to the field fallback, with reasons in the code: Taproot/P2WSH destinations (32-byte programs don't fit the legacy packing), subassets (ledger-resolved), BTC, and any memo core's encoder would silently drop (over 63 bytes, odd-length hex — core wraps memo encoding in a bare except). Declining is the honest mirror: byte-agreeing with a message that ignored the user's memo would verify the very substitution the comparison exists to catch.

unpack/address.ts gains packAddressLegacy, the mirror of the 21-byte LUT slots the decoder already reads.

Oracles and tests

  • Unit fixtures generated with core's own mpmaencoding functions under bitstring 4.1.4 (LUT + bit layout, per-send hex memos, whole-send memo, the nbits-zero shape).
  • Compose oracle: four MPMA cases, including the repeated-key memo transport proof and the nbits-zero shape.
  • Round-trip oracle: rebuilds recent on-chain MPMA sends byte-for-byte (with documented skips for shapes that decode ambiguously, e.g. a whole-send memo copied into every send).

Verification

  • tsc --noEmit clean; all 678 counterparty tests pass with the oracles running against api.counterparty.io:4000; contexts/pages/components subsets pass.
  • E2E locally, one file at a time: compose/send/mpma.spec.ts (6/6), compose/send/index.spec.ts (20/20).

https://claude.ai/code/session_01CcjnCrgosSeshymXLBxdGj

MPMA packs as a >H-counted LUT of legacy 21-byte packed addresses
sorted lexicographically, then a bit stream: a whole-send memo, and
per asset (sorted by name) a continuation bit, the 64-bit asset id, an
nbits-wide send count less one, and each send's nbits-wide LUT index,
64-bit quantity and memo, terminated by a zero bit and padded to a
byte. One distinct destination makes nbits zero and the count and
index fields occupy no bits at all -- core's pinned bitstring (4.1.4)
appends nothing for uint:0 where newer versions raise, a difference
that made the dominant on-chain shape (several assets to one address)
look uncomposable until the fixture generator ran under the pin. The
same version lesson as cbor2: check what core actually runs.

Both wallet flows now byte-verify: the dedicated MPMA page, whose
quantity normalization moves from the page's compose method into
normalizeFormData so verification sees the base units the API
receives, and the send form's multi-destination convenience, which
packs as the equivalent MPMA message. Declined to field comparison:
Taproot and P2WSH destinations (a 32-byte program does not fit the
legacy packing), subassets (ledger-resolved), BTC, and any memo core's
encoder would silently drop -- over 63 bytes, or odd-length hex --
because byte-agreeing with a message that ignored the user's memo
would verify the very substitution the comparison exists to catch.

Fixing the packer surfaced that MPMA memos never reached the API at
all: composeMPMA sent them as `memos[]=` and core's query_params()
does nothing with a PHP-style suffix, so compose silently proceeded
memo-less. They now travel as repeated plain `memos=` keys, which core
folds into a list. A memo shared by every destination travels once as
the whole-send `memo` param instead of a per-send list, and mixed
hex/text memo lists are refused loudly, since core applies a single
memos_are_hex flag to the whole list and cannot express the mix.

unpack/address.ts gains packAddressLegacy, the mirror of the LUT slots
the decoder already reads. Fixtures were generated with core's own
mpmaencoding functions under bitstring 4.1.4; the compose oracle gains
four MPMA cases (including one proving the repeated-key memo transport
against a live node) and the round-trip oracle now rebuilds real
on-chain MPMA sends byte-for-byte. All 678 counterparty tests pass
with the oracles live; the MPMA and send compose E2E specs pass
locally, one file at a time.

Claude-Session: https://claude.ai/code/session_01CcjnCrgosSeshymXLBxdGj
@droplister
droplister merged commit 3d1eb45 into main Aug 3, 2026
38 checks passed
@droplister
droplister deleted the harden/byte-equality-mpma branch August 3, 2026 20:42
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.

1 participant