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[] = [];