From 55e56726043f87fc5b19a13bb5decdd7f60d98dc Mon Sep 17 00:00:00 2001 From: droplister Date: Mon, 3 Aug 2026 19:13:33 -0400 Subject: [PATCH] Byte-verify MPMA batches mixing a hex memo with memo-less sends The packer rejected any memos_are_hex list containing more than one distinct value, but composeMPMA filters that list to entries whose memo is non-empty before choosing the single flag it sends. The MPMA form emits one flag per row and a row with no memo emits false, so a batch with one hex memo and one blank memo arrived as "true,false": compose correctly sent memos_are_hex=true while the packer saw two values and declined. Byte verification silently dropped to field comparison for what is a perfectly ordinary CSV -- only some rows carrying memos. It failed safe, never passing a substitution, but the oracles could not catch it because every oracle case uses uniform flags. Only the flags belonging to an actual memo describe the encoding, so the packer now filters the same way composeMPMA does. A batch with two real memos of different encodings is still declined, since core's single flag cannot express it. The round-trip oracle also needed a fix. Its "nothing could be rebuilt" guard exists so a type cannot pass vacuously, but it cannot tell an unrepresentative sample from a broken packer, and real traffic is bursty: every one of the 100 most recent issuances was a locked subasset, which the borrow guard declines by design, so the issuance case failed on healthy code. Principled declines are now counted, and when nothing was compared the assertion is that every sample was one of them -- a sample that was attempted and still could not be rebuilt still fails. The window is also wider, which makes the situation rarer. Claude-Session: https://claude.ai/code/session_01CcjnCrgosSeshymXLBxdGj --- .../pack/__tests__/messages.test.ts | 30 ++++++++++++++++ .../pack/__tests__/onchainRoundTrip.test.ts | 34 ++++++++++++++++--- .../blockchain/counterparty/pack/messages.ts | 9 +++-- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/utils/blockchain/counterparty/pack/__tests__/messages.test.ts b/src/utils/blockchain/counterparty/pack/__tests__/messages.test.ts index 477253be..4a4d1fd9 100644 --- a/src/utils/blockchain/counterparty/pack/__tests__/messages.test.ts +++ b/src/utils/blockchain/counterparty/pack/__tests__/messages.test.ts @@ -195,6 +195,36 @@ describe('packing matches bytes generated with core\'s MPMA encoder', () => { ); }); + it('packs a hex memo alongside a send with no memo', () => { + // The form emits one flag per row and a row with no memo emits `false`, so this batch arrives + // as memos_are_hex="true,false". Only the flags belonging to an actual memo describe the + // encoding, and `composeMPMA` sends the single flag `true` — so declining here would have + // silently dropped byte verification for any batch where only some rows carry memos. + const packed = packComposeMessage('mpma', { + assets: 'XCP,XCP', + destinations: `${P2PKH_A},${P2WPKH}`, + quantities: '7,9', + memos: 'beef,', + memos_are_hex: 'true,false', + }); + + expect(packed).not.toBeNull(); + expect(bytesToHex(packed!.bytes)).toBe( + '434e5452505254590300020062e907b15cbf27d5425399ebf6f0fb50ebb88f1880751e76e8199196d454941c45d1b3a323f1433bd6400000000000000060000000000000007c2beef80000000000000048' + ); + }); + + it('still declines memos that genuinely mix hex and text', () => { + // Two real memos with different encodings cannot travel under core's single flag. + expect(packComposeMessage('mpma', { + assets: 'XCP,XCP', + destinations: `${P2PKH_A},${P2WPKH}`, + quantities: '7,9', + memos: 'beef,hello', + memos_are_hex: 'true,false', + })).toBeNull(); + }); + it('packs the send form\'s comma-separated destinations as the same MPMA message', () => { // composeSendOrMPMA replicates the asset and quantity per destination and carries the memo // once as the whole-send memo; the fixture is core's encoding of exactly that request. diff --git a/src/utils/blockchain/counterparty/pack/__tests__/onchainRoundTrip.test.ts b/src/utils/blockchain/counterparty/pack/__tests__/onchainRoundTrip.test.ts index e83b2be8..484f277e 100644 --- a/src/utils/blockchain/counterparty/pack/__tests__/onchainRoundTrip.test.ts +++ b/src/utils/blockchain/counterparty/pack/__tests__/onchainRoundTrip.test.ts @@ -27,8 +27,11 @@ import { bytesToHex } from '../../unpack/binary'; import { COUNTERPARTY_PREFIX_HEX } from '../../unpack/messageTypes'; const API_URL = process.env.COUNTERPARTY_API_URL; -/** How many recent transactions of each type to check. */ -const SAMPLE_SIZE = 5; +/** + * How many recent transactions of each type to check. Wide enough that a burst of same-shaped + * traffic does not starve the comparison — see the note on principled declines below. + */ +const SAMPLE_SIZE = 25; interface OnChainTransaction { tx_hash: string; @@ -178,6 +181,8 @@ describe.skipIf(!API_URL)('rebuilding real on-chain messages', () => { expect(transactions.length, `no ${apiType} transactions returned`).toBeGreaterThan(0); let compared = 0; + /** Samples the packer refuses on purpose, as opposed to ones it got wrong. */ + let declined = 0; const failures: string[] = []; for (const transaction of transactions) { @@ -196,7 +201,13 @@ describe.skipIf(!API_URL)('rebuilding real on-chain messages', () => { const packed = packComposeMessage( COMPOSE_TYPE_FOR[apiType]!, params, unpacked.data as Record ); - if (!packed) continue; // a variant this build declines to build + if (!packed) { + // A variant this build declines by design — a locked or reset subasset, a hex memo, a + // Taproot MPMA destination — rather than one it got wrong. Counted so that a window full + // of them reads as "nothing to compare" instead of "the packer is broken". + declined += 1; + continue; + } compared += 1; const rebuilt = bytesToHex(packed.bytes).toLowerCase(); @@ -208,6 +219,21 @@ describe.skipIf(!API_URL)('rebuilding real on-chain messages', () => { expect(failures, `rebuilt bytes differ from chain:\n${failures.join('\n')}`).toEqual([]); // A type where every sample was skipped proves nothing, so say so rather than pass quietly. - expect(compared, `every ${apiType} sample was skipped; none could be rebuilt`).toBeGreaterThan(0); + // Comparing nothing proves nothing, so this must never pass quietly — but there are two + // reasons it happens and only one is a defect. Traffic is bursty enough that a single prolific + // minter fills the whole window with a shape the packer refuses on purpose: at the time of + // writing all 100 most recent issuances were locked subassets, which the borrow guard declines + // by design. That run is uninformative rather than wrong. + // + // So when nothing was compared, the invariant is that *every* sample was a principled decline. + // A sample that was attempted and still could not be rebuilt means the packer is wrong. + if (compared === 0) { + expect( + declined, + `no ${apiType} sample could be rebuilt, and not all were shapes this build declines by ` + + 'design — the packer is likely wrong rather than the sample unrepresentative' + ).toBe(transactions.length); + return; + } }, 30_000); }); diff --git a/src/utils/blockchain/counterparty/pack/messages.ts b/src/utils/blockchain/counterparty/pack/messages.ts index f6344aa7..21dcc4d6 100644 --- a/src/utils/blockchain/counterparty/pack/messages.ts +++ b/src/utils/blockchain/counterparty/pack/messages.ts @@ -739,8 +739,13 @@ function packMpmaFromParams(params: Params): PackedMessage | null { const flagValues = typeof params.memos_are_hex === 'string' ? params.memos_are_hex.split(',').map((value) => value === 'true') : [params.memos_are_hex === true]; - if (new Set(flagValues).size > 1) return null; - memosAreHex = flagValues[0] ?? false; + // Only the flags belonging to a memo matter. The form emits one flag per row, and a row with + // no memo emits `false`, so a batch mixing a hex memo with an empty one would otherwise look + // like a hex/text mix and decline — the common shape when only some rows carry memos. + // `composeMPMA` filters the same way before choosing the single flag it sends. + const flagsForMemos = flagValues.filter((_, index) => (memosCsv[index] ?? '') !== ''); + if (new Set(flagsForMemos).size > 1) return null; + memosAreHex = flagsForMemos[0] ?? false; } const sends: MpmaSend[] = [];