Extend byte equality to MPMA, and fix memo transport the API ignored - #216
Merged
Conversation
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
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
composeMPMAsent per-send memos asmemos[]=query params. Core'squery_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 repeatedmemos=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:
memoparam instead of a per-send list saying the same thing n times.memos_are_hexflag 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 a0bit, 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:
normalizeFormData, so verification sees the same base units the API receives;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.tsgainspackAddressLegacy, the mirror of the 21-byte LUT slots the decoder already reads.Oracles and tests
mpmaencodingfunctions under bitstring 4.1.4 (LUT + bit layout, per-send hex memos, whole-send memo, the nbits-zero shape).Verification
tsc --noEmitclean; all 678 counterparty tests pass with the oracles running againstapi.counterparty.io:4000; contexts/pages/components subsets pass.compose/send/mpma.spec.ts(6/6),compose/send/index.spec.ts(20/20).https://claude.ai/code/session_01CcjnCrgosSeshymXLBxdGj