From 2e2db862633ee46dda05bf82c457eb6b4bfc5d38 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 20:37:59 -0700 Subject: [PATCH 1/5] Implement multi-push encoding for Memo multi-field actions Reply, topic message, add-poll-option, poll-vote, and create-poll now broadcast each protocol field as its own OP_RETURN script push instead of combining them into one push. The indexer requires three pushes for reply, topic message, and poll children, and accepts four for create-poll; the combined form was rejected or misparsed. hex.js builds the txid and text as separate pushes, and a new memo-multipush adapter teaches minimal-slp-wallet's single-push sendOpReturn to expand an array of fields into separate pushes. Acceptance step handlers assert the individual pushes, and unit/property tests cover the new shape. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 133 +++++++++++++++++- psf-memo-client/src/services/async-load.js | 9 ++ psf-memo-client/src/services/hex.js | 15 +- .../src/services/memo-multipush.js | 89 ++++++++++++ .../src/services/memo-poll-create.js | 20 +-- .../src/services/memo-poll-option.js | 4 +- .../src/services/memo-poll-vote.js | 4 +- psf-memo-client/src/services/memo-reply.js | 8 +- .../src/services/memo-topic-post.js | 7 +- .../property/poll-services.property.test.js | 13 +- .../txid-wire-encoding.property.test.js | 10 +- psf-memo-client/test/unit/hex.test.js | 25 ++-- .../test/unit/memo-multipush.test.js | 123 ++++++++++++++++ .../test/unit/memo-poll-create.test.js | 21 +-- .../test/unit/memo-poll-option.test.js | 17 +-- .../test/unit/memo-poll-vote.test.js | 17 +-- psf-memo-client/test/unit/memo-reply.test.js | 10 +- .../test/unit/memo-topic-post.test.js | 8 +- 18 files changed, 431 insertions(+), 102 deletions(-) create mode 100644 psf-memo-client/src/services/memo-multipush.js create mode 100644 psf-memo-client/test/unit/memo-multipush.test.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index b46f596..a251274 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -58,6 +58,7 @@ const { renderPostOptions } = require('./render-post-options') const { renderLikeResult } = require('./render-like-result') const PostOptions = require('../../src/services/post-options') const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed') +const { toPushBuffer } = require('../../src/services/memo-multipush') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX @@ -102,12 +103,19 @@ function makeWallet (address) { return this.utxos }, sendOpReturn: async function (msg, prefix, bchOutput = []) { - // Normalize binary payloads to Buffer so assertions can safely use - // toString('hex'), while preserving string payloads unchanged. - const storedMsg = (msg instanceof Uint8Array || ArrayBuffer.isView(msg)) - ? Buffer.from(msg) - : msg - this.broadcasts.push({ msg: storedMsg, prefix, bchOutput }) + // Multi-field actions pass an array of field pushes. Expose each push + // separately for the multi-push assertions, while keeping `msg` as the + // concatenated payload so older combined-payload assertions still work. + const isMulti = Array.isArray(msg) + const fields = isMulti ? msg : [msg] + const fieldBuffers = fields.map((field) => toPushBuffer(field)) + const storedMsg = isMulti + ? Buffer.concat(fieldBuffers) + : ((msg instanceof Uint8Array || ArrayBuffer.isView(msg)) + ? Buffer.from(msg) + : msg) + const pushes = [Buffer.from(prefix, 'hex'), ...fieldBuffers] + this.broadcasts.push({ msg: storedMsg, pushes, prefix, bchOutput }) if (this.failWith) throw new Error(this.failWith) return 'aa'.repeat(32) } @@ -559,6 +567,28 @@ function decodeLikeTxid (raw) { return Buffer.from(raw).reverse().toString('hex') } +// Return the most recent wallet broadcast, or fail when none was sent. +function lastBroadcast (world) { + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.') + return broadcasts[broadcasts.length - 1] +} + +// Return the push at the given zero-based index from the most recent +// broadcast. Index 0 is the action prefix; later indexes are the fields. +function broadcastPush (world, index) { + const last = lastBroadcast(world) + if (!Array.isArray(last.pushes) || last.pushes.length <= index) { + throw new Error(`Broadcast does not have a push at index ${index}.`) + } + return Buffer.from(last.pushes[index]) +} + +// Reverse a display txid into little-endian wire hex. +function txidWireHex (txid) { + return Buffer.from(txid, 'hex').reverse().toString('hex') +} + // Resolve a literal value or a placeholder from the example store. function resolveParam (value, example) { const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim()) @@ -1046,6 +1076,94 @@ const handlers = [ } } }, + { + name: 'wallet broadcasts multi-push OP_RETURN with prefix', + pattern: /^the wallet broadcasts an OP_RETURN with the Memo (reply|topic-message|add-poll-option|poll-vote|create-poll) prefix and (\d+) pushes$/, + run (m, example, world) { + const prefix = { + reply: MEMO_REPLY_PREFIX, + 'topic-message': MEMO_TOPIC_MESSAGE_PREFIX, + 'add-poll-option': MEMO_ADD_POLL_OPTION_PREFIX, + 'poll-vote': MEMO_POLL_VOTE_PREFIX, + 'create-poll': MEMO_CREATE_POLL_PREFIX + }[m[1]] + const expectedPushes = parseInt(m[2], 10) + const last = lastBroadcast(world) + if (last.prefix !== prefix) { + throw new Error(`Expected Memo ${m[1]} prefix ${prefix}, got "${last.prefix}".`) + } + const actual = Array.isArray(last.pushes) ? last.pushes.length : 0 + if (actual !== expectedPushes) { + throw new Error(`Expected ${expectedPushes} OP_RETURN pushes, got ${actual}.`) + } + } + }, + { + name: 'second broadcast push is referenced txid in wire order', + pattern: /^the second broadcast push is the referenced txid (.+) in little-endian wire order$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const push = broadcastPush(world, 1) + if (push.toString('hex') !== txidWireHex(txid)) { + throw new Error(`Second push did not match the wire txid ${txid}.`) + } + } + }, + { + name: 'second broadcast push is UTF-8 topic', + pattern: /^the second broadcast push is the UTF-8 topic "(.+)"$/, + run (m, example, world) { + const topic = resolveText(m[1], example) + const push = broadcastPush(world, 1) + if (push.toString('utf8') !== topic) { + throw new Error(`Second push "${push.toString('utf8')}" did not match topic "${topic}".`) + } + } + }, + { + name: 'second broadcast push is poll type', + pattern: /^the second broadcast push is the poll type (\d+)$/, + run (m, example, world) { + const expected = parseInt(m[1], 10) + const push = broadcastPush(world, 1) + if (push.length !== 1 || push[0] !== expected) { + throw new Error(`Second push was not the poll type ${expected}.`) + } + } + }, + { + name: 'third broadcast push is UTF-8 text', + pattern: /^the third broadcast push is the UTF-8 text "(.+)"$/, + run (m, example, world) { + const text = resolveText(m[1], example) + const push = broadcastPush(world, 2) + if (push.toString('utf8') !== text) { + throw new Error(`Third push "${push.toString('utf8')}" did not match text "${text}".`) + } + } + }, + { + name: 'third broadcast push is option count', + pattern: /^the third broadcast push is the option count (.+)$/, + run (m, example, world) { + const expected = parseInt(resolveParam(m[1], example), 10) + const push = broadcastPush(world, 2) + if (push.length !== 1 || push[0] !== expected) { + throw new Error(`Third push was not the option count ${expected}.`) + } + } + }, + { + name: 'fourth broadcast push is UTF-8 question', + pattern: /^the fourth broadcast push is the UTF-8 question "(.+)"$/, + run (m, example, world) { + const question = resolveText(m[1], example) + const push = broadcastPush(world, 3) + if (push.toString('utf8') !== question) { + throw new Error(`Fourth push "${push.toString('utf8')}" did not match question "${question}".`) + } + } + }, { name: 'thread shows new reply from my address', pattern: /^the thread shows a new reply from my address with the text "<([A-Za-z0-9_]+)>"$/, @@ -2223,7 +2341,8 @@ const handlers = [ throw new Error(`Expected Memo topic-message prefix ${MEMO_TOPIC_MESSAGE_PREFIX}, got "${last.prefix}".`) } const expectedPayload = room + world.topicPostPage.input - if (last.msg !== expectedPayload) { + const actualPayload = Buffer.isBuffer(last.msg) ? last.msg.toString('utf8') : last.msg + if (actualPayload !== expectedPayload) { throw new Error(`Broadcast topic-message payload did not match ${room} + input.`) } } diff --git a/psf-memo-client/src/services/async-load.js b/psf-memo-client/src/services/async-load.js index 2b5bcec..3ca310d 100644 --- a/psf-memo-client/src/services/async-load.js +++ b/psf-memo-client/src/services/async-load.js @@ -6,6 +6,7 @@ import axios from 'axios' // Local libraries import GistServers from './gist-servers' +import memoMultipush from './memo-multipush' class AsyncLoad { constructor () { @@ -66,6 +67,10 @@ class AsyncLoad { this.wallet = wallet + // Teach the wallet to broadcast multi-field Memo actions as separate + // OP_RETURN pushes. + memoMultipush.attachMultiPushOpReturn(wallet) + return wallet } catch (error) { console.error('Error initializing wallet: ', error) @@ -240,6 +245,10 @@ class AsyncLoad { this.wallet = wallet + // Teach the wallet to broadcast multi-field Memo actions as separate + // OP_RETURN pushes. + memoMultipush.attachMultiPushOpReturn(wallet) + return wallet } catch (error) { console.error('Error initStarterWallet: ', error) diff --git a/psf-memo-client/src/services/hex.js b/psf-memo-client/src/services/hex.js index c18099c..be2ad84 100644 --- a/psf-memo-client/src/services/hex.js +++ b/psf-memo-client/src/services/hex.js @@ -33,19 +33,16 @@ function txidToWireBytes (txid, label = 'Value') { return hexToBytes(txid, 32, label).reverse() } -// Build the raw OP_RETURN payload for a txid-referencing Memo action: the -// given 32-byte txid in little-endian wire order followed by a UTF-8 encoded -// value. The label customizes the invalid-txid error message. -function buildTxidTextPayload (txid, text, label = 'Poll txid') { +// Build the separate OP_RETURN pushes for a txid-referencing Memo action: +// the referenced txid in little-endian wire order and the UTF-8 encoded value, +// each as its own push. The label customizes the invalid-txid error message. +function buildTxidTextPushes (txid, text, label = 'Poll txid') { const txidBytes = txidToWireBytes(txid, label) const textBytes = new TextEncoder().encode(text) - const raw = new Uint8Array(txidBytes.length + textBytes.length) - raw.set(txidBytes, 0) - raw.set(textBytes, txidBytes.length) - return raw + return [txidBytes, textBytes] } -module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPayload } +module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPushes } // mutate4javascript-manifest-begin // {"version":1,"tested_at":"2026-09-16T22:01:27.280Z","module_hash":"454b45f3ce08c5ea6346c113db6384f7a44dddbd21e2fb0a35c41ce6701e62c8","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":12,"end_line":26,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/txidToWireBytes","name":"txidToWireBytes","line":32,"end_line":34,"hash":"ac25753fecb32c929134b6e6379cae0bfddd4aa6f7ae438a08da650450e9ea1a"},{"id":"func/buildTxidTextPayload","name":"buildTxidTextPayload","line":39,"end_line":46,"hash":"068504ea13a037adabbbfeab122fb5ec6113625a9e0b009cbbe8b46b6bebdaee"}]} diff --git a/psf-memo-client/src/services/memo-multipush.js b/psf-memo-client/src/services/memo-multipush.js new file mode 100644 index 0000000..6f0f5fe --- /dev/null +++ b/psf-memo-client/src/services/memo-multipush.js @@ -0,0 +1,89 @@ +/* + Multi-push OP_RETURN adapter for Memo actions that carry several fields. + + Memo multi-field actions (reply, topic message, add-poll-option, poll-vote, + and create-poll) must encode each protocol field as its own OP_RETURN script + push: OP_RETURN ... . minimal-slp-wallet's + sendOpReturn(msg, prefix, bchOutput) pushes its msg as a single push, so this + adapter wraps a wallet's sendOpReturn: an array first argument is broadcast as + separate pushes, while every existing single-field call is delegated to the + original wallet method unchanged. + + The adapter reuses the wallet's own OP_RETURN transaction builder for fee + selection, change, signing, and broadcast. Only the OP_RETURN script it + composes is expanded. The pure push-normalization helpers are unit tested; + the wallet wiring is the small environmentally unsuitable boundary. +*/ + +// Normalize one push value (a UTF-8 string or byte array) to a Buffer. +function toPushBuffer (value) { + if (typeof value === 'string') return Buffer.from(value, 'utf8') + if (value instanceof Uint8Array) return Buffer.from(value) + return Buffer.from(value) +} + +// Build the ordered OP_RETURN pushes: the action prefix bytes first, then each +// field as its own push. +function buildPushes (prefix, fields) { + return [Buffer.from(prefix, 'hex'), ...fields.map(toPushBuffer)] +} + +// Broadcast fields as separate OP_RETURN pushes using the wallet's own +// transaction builder. The wallet must expose minimal-slp-wallet's +// opReturn.createTransaction and opReturn.ar.sendTx. +async function broadcastMultiPush (wallet, fields, prefix, bchOutput = []) { + const opReturn = wallet.opReturn + if (!opReturn || typeof opReturn.createTransaction !== 'function') { + throw new Error('Wallet does not support multi-push OP_RETURN broadcasts.') + } + + await wallet.walletInfoPromise + + const bchjs = opReturn.bchjs + const originalEncode2 = bchjs.Script.encode2 + const pushes = buildPushes(prefix, fields) + + // createTransaction composes [OP_RETURN, prefix, msg] and calls encode2 + // synchronously before any await. Swap in the multi-push script for that one + // call, then restore immediately so concurrent wallet work is unaffected. + bchjs.Script.encode2 = function (script) { + bchjs.Script.encode2 = originalEncode2 + return originalEncode2.call(this, [script[0], ...pushes]) + } + + try { + const { hex } = await opReturn.createTransaction( + wallet.walletInfo, + wallet.utxos.utxoStore.bchUtxos, + '', + prefix, + bchOutput, + wallet.fee + ) + return await opReturn.ar.sendTx(hex) + } finally { + bchjs.Script.encode2 = originalEncode2 + } +} + +// Wrap a wallet's sendOpReturn so an array first argument broadcasts each +// element as its own OP_RETURN push. Single-field calls delegate unchanged. +function attachMultiPushOpReturn (wallet) { + if (!wallet || wallet.__multiPushAttached) return wallet + const original = wallet.sendOpReturn.bind(wallet) + wallet.sendOpReturn = function (msgOrFields, prefix, bchOutput = []) { + if (Array.isArray(msgOrFields)) { + return broadcastMultiPush(wallet, msgOrFields, prefix, bchOutput) + } + return original(msgOrFields, prefix, bchOutput) + } + wallet.__multiPushAttached = true + return wallet +} + +module.exports = { + toPushBuffer, + buildPushes, + broadcastMultiPush, + attachMultiPushOpReturn +} diff --git a/psf-memo-client/src/services/memo-poll-create.js b/psf-memo-client/src/services/memo-poll-create.js index 3ffb825..aae56f5 100644 --- a/psf-memo-client/src/services/memo-poll-create.js +++ b/psf-memo-client/src/services/memo-poll-create.js @@ -62,8 +62,8 @@ class MemoPollCreate extends MemoAction { await this.wallet.getUtxos() - const raw = buildCreatePollPayload(question, this.pollType, count) - const txid = await this.wallet.sendOpReturn(raw, this.prefix) + const pushes = buildCreatePollPushes(question, this.pollType, count) + const txid = await this.wallet.sendOpReturn(pushes, this.prefix) this.reflect(txid, question, count) @@ -84,15 +84,15 @@ class MemoPollCreate extends MemoAction { } } -// Build the raw OP_RETURN message payload for a create-poll action. -// The protocol wire format is: . -function buildCreatePollPayload (question, pollType, optionCount) { +// Build the separate OP_RETURN pushes for a create-poll action: the poll type +// byte, the option count byte, and the UTF-8 question, each as its own push. +function buildCreatePollPushes (question, pollType, optionCount) { const textBytes = new TextEncoder().encode(question) - const raw = new Uint8Array(2 + textBytes.length) - raw[0] = pollType & 0xff - raw[1] = optionCount & 0xff - raw.set(textBytes, 2) - return raw + return [ + Uint8Array.from([pollType & 0xff]), + Uint8Array.from([optionCount & 0xff]), + textBytes + ] } MemoPollCreate.MEMO_CREATE_POLL_PREFIX = MEMO_CREATE_POLL_PREFIX diff --git a/psf-memo-client/src/services/memo-poll-option.js b/psf-memo-client/src/services/memo-poll-option.js index 2a70d4b..b8455ce 100644 --- a/psf-memo-client/src/services/memo-poll-option.js +++ b/psf-memo-client/src/services/memo-poll-option.js @@ -15,7 +15,7 @@ */ const MemoTxidAction = require('./memo-txid-action') -const { buildTxidTextPayload } = require('./hex') +const { buildTxidTextPushes } = require('./hex') const MEMO_ADD_POLL_OPTION_PREFIX = '6d13' const MAX_OPTION_BYTES = 184 @@ -35,7 +35,7 @@ class MemoPollOption extends MemoTxidAction { // Compose and broadcast a Memo add-poll-option action. add (option) { - return this.broadcastTxid(option, buildTxidTextPayload) + return this.broadcastTxid(option, buildTxidTextPushes) } } diff --git a/psf-memo-client/src/services/memo-poll-vote.js b/psf-memo-client/src/services/memo-poll-vote.js index 896d6d0..08d3d8e 100644 --- a/psf-memo-client/src/services/memo-poll-vote.js +++ b/psf-memo-client/src/services/memo-poll-vote.js @@ -15,7 +15,7 @@ */ const MemoTxidAction = require('./memo-txid-action') -const { buildTxidTextPayload } = require('./hex') +const { buildTxidTextPushes } = require('./hex') const MEMO_POLL_VOTE_PREFIX = '6d14' const MAX_COMMENT_BYTES = 184 @@ -35,7 +35,7 @@ class MemoPollVote extends MemoTxidAction { // Compose and broadcast a Memo poll-vote action. vote (comment) { - return this.broadcastTxid(comment, buildTxidTextPayload) + return this.broadcastTxid(comment, buildTxidTextPushes) } } diff --git a/psf-memo-client/src/services/memo-reply.js b/psf-memo-client/src/services/memo-reply.js index 30cbf4a..a7fa557 100644 --- a/psf-memo-client/src/services/memo-reply.js +++ b/psf-memo-client/src/services/memo-reply.js @@ -18,7 +18,7 @@ const MemoAction = require('./memo-action') const { byteLength } = require('./utf8') -const { buildTxidTextPayload } = require('./hex') +const { buildTxidTextPushes } = require('./hex') const MEMO_REPLY_PREFIX = '6d03' const MAX_REPLY_BYTES = 184 @@ -56,9 +56,9 @@ class MemoReply extends MemoAction { // Refresh the wallet's spendable UTXO store so the broadcast has inputs. await this.wallet.getUtxos() - // Build the raw payload: parent txid bytes followed by UTF-8 message bytes. - const raw = buildTxidTextPayload(parentTxid, message, 'Parent txid') - const txid = await this.wallet.sendOpReturn(raw, this.prefix) + // Build the separate pushes: parent txid bytes, then UTF-8 message bytes. + const pushes = buildTxidTextPushes(parentTxid, message, 'Parent txid') + const txid = await this.wallet.sendOpReturn(pushes, this.prefix) // Reflect the result on the injected thread once broadcast succeeds. this.reflect(txid, message, parentTxid) diff --git a/psf-memo-client/src/services/memo-topic-post.js b/psf-memo-client/src/services/memo-topic-post.js index 7d08b70..01f2848 100644 --- a/psf-memo-client/src/services/memo-topic-post.js +++ b/psf-memo-client/src/services/memo-topic-post.js @@ -58,8 +58,11 @@ class MemoTopicPost extends MemoAction { await this.wallet.getUtxos() - const payload = this.room + message - const txid = await this.wallet.sendOpReturn(payload, this.prefix) + const pushes = [ + new TextEncoder().encode(this.room), + new TextEncoder().encode(message) + ] + const txid = await this.wallet.sendOpReturn(pushes, this.prefix) this.reflect(txid, message) diff --git a/psf-memo-client/test/property/poll-services.property.test.js b/psf-memo-client/test/property/poll-services.property.test.js index b3e253a..698b3f7 100644 --- a/psf-memo-client/test/property/poll-services.property.test.js +++ b/psf-memo-client/test/property/poll-services.property.test.js @@ -4,8 +4,8 @@ These pin down invariants over broad random inputs that the unit tests only probe at fixed fixtures: - - Round-trip: buildTxidTextPayload encodes a poll txid + text into the - canonical Memo wire payload, so the stored reverse-hex txid and the + - Round-trip: buildTxidTextPushes encodes a poll txid + text into the + canonical Memo wire pushes, so the stored reverse-hex txid and the UTF-8 text both decode back unchanged. - hexToBytes length contract: only 64-character hex txids decode to 32 bytes; everything else throws. @@ -19,7 +19,7 @@ const test = require('node:test') const { seededRandom, forAll } = require('./harness') -const { hexToBytes, buildTxidTextPayload } = require('../../src/services/hex') +const { hexToBytes, buildTxidTextPushes } = require('../../src/services/hex') const { byteLength } = require('../../src/services/utf8') const MemoPollOption = require('../../src/services/memo-poll-option') const MemoPollVote = require('../../src/services/memo-poll-vote') @@ -61,13 +61,14 @@ function makeWallet () { } } -test('buildTxidTextPayload round-trips the canonical Memo wire format', async () => { +test('buildTxidTextPushes round-trips the canonical Memo wire format', async () => { await forAll( () => ({ txid: randomTxid(), text: randomText(200) }), ({ txid, text }) => { - const bytes = buildTxidTextPayload(txid, text) + const pushes = buildTxidTextPushes(txid, text) + const bytes = Buffer.concat(pushes.map((p) => Buffer.from(p))) if (bytes.length !== 32 + byteLength(text)) return false - // The payload carries the txid in little-endian wire order, followed by + // The pushes carry the txid in little-endian wire order, followed by // the UTF-8 bytes of the value. Reversing the wire bytes must recover // the 64-character display txid. const wire = Buffer.from(bytes.slice(0, 32)) diff --git a/psf-memo-client/test/property/txid-wire-encoding.property.test.js b/psf-memo-client/test/property/txid-wire-encoding.property.test.js index bbf0003..1530212 100644 --- a/psf-memo-client/test/property/txid-wire-encoding.property.test.js +++ b/psf-memo-client/test/property/txid-wire-encoding.property.test.js @@ -18,7 +18,7 @@ const test = require('node:test') const assert = require('node:assert/strict') const { seededRandom, forAll, intGen } = require('./harness') -const { txidToWireBytes, buildTxidTextPayload } = require('../../src/services/hex') +const { txidToWireBytes, buildTxidTextPushes } = require('../../src/services/hex') const HEX_CHARS = '0123456789abcdef' @@ -87,8 +87,8 @@ test('payload embeds the wire txid followed by the UTF-8 text', async () => { await forAll( () => ({ txid: randomTxid(rng), text: randomText(rng) }), ({ txid, text }) => { - const raw = buildTxidTextPayload(txid, text) - const buf = Buffer.from(raw) + const pushes = buildTxidTextPushes(txid, text) + const buf = Buffer.concat(pushes.map((p) => Buffer.from(p))) const wire = Buffer.from(txidToWireBytes(txid)).toString('hex') const expectedText = Buffer.from(text, 'utf8') @@ -96,13 +96,13 @@ test('payload embeds the wire txid followed by the UTF-8 text', async () => { if (buf.subarray(0, 32).toString('hex') !== wire) return false return buf.subarray(32).equals(expectedText) }, - { label: 'buildTxidTextPayload shape' } + { label: 'buildTxidTextPushes shape' } ) }) test('a reply payload rejects an invalid txid with the parent label', () => { assert.throws( - () => buildTxidTextPayload('not-a-txid', 'hi', 'Parent txid'), + () => buildTxidTextPushes('not-a-txid', 'hi', 'Parent txid'), /Parent txid must be a 64-character hex string/ ) }) diff --git a/psf-memo-client/test/unit/hex.test.js b/psf-memo-client/test/unit/hex.test.js index a907922..9812ac8 100644 --- a/psf-memo-client/test/unit/hex.test.js +++ b/psf-memo-client/test/unit/hex.test.js @@ -4,14 +4,14 @@ Memo actions that embed a parent poll txid use hexToBytes to decode the 64-character hex txid into 32 raw bytes. These direct tests pin the length and hex-validity guards independently of the memo-poll broadcast path that - also reaches hexToBytes through buildTxidTextPayload. + also reaches hexToBytes through buildTxidTextPushes. */ 'use strict' const test = require('node:test') const assert = require('node:assert/strict') -const { hexToBytes, buildTxidTextPayload, txidToWireBytes } = require('../../src/services/hex') +const { hexToBytes, buildTxidTextPushes, txidToWireBytes } = require('../../src/services/hex') // A non-palindromic txid so a missing byte reversal is observable. const DISPLAY_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' @@ -57,13 +57,13 @@ test('hexToBytes rejects a non-string value', () => { ) }) -test('buildTxidTextPayload prefixes the raw txid bytes', () => { - const raw = buildTxidTextPayload('ab'.repeat(32), 'hi') - const buf = Buffer.from(raw) +test('buildTxidTextPushes returns the txid and text as separate pushes', () => { + const pushes = buildTxidTextPushes('ab'.repeat(32), 'hi') - assert.equal(buf.length, 32 + 2) - assert.equal(buf[0], 0xab) - assert.equal(buf.slice(32).toString('utf8'), 'hi') + assert.equal(pushes.length, 2) + assert.equal(Buffer.from(pushes[0]).length, 32) + assert.equal(Buffer.from(pushes[0])[0], 0xab) + assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hi') }) test('txidToWireBytes reverses the display txid into little-endian wire order', () => { @@ -80,10 +80,9 @@ test('txidToWireBytes rejects an invalid txid', () => { ) }) -test('buildTxidTextPayload embeds the txid in little-endian wire order', () => { - const raw = buildTxidTextPayload(DISPLAY_TXID, 'hi') - const buf = Buffer.from(raw) +test('buildTxidTextPushes embeds the txid in little-endian wire order', () => { + const pushes = buildTxidTextPushes(DISPLAY_TXID, 'hi') - assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX) - assert.equal(buf.slice(32).toString('utf8'), 'hi') + assert.equal(Buffer.from(pushes[0]).toString('hex'), WIRE_HEX) + assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hi') }) diff --git a/psf-memo-client/test/unit/memo-multipush.test.js b/psf-memo-client/test/unit/memo-multipush.test.js new file mode 100644 index 0000000..b8b9fd3 --- /dev/null +++ b/psf-memo-client/test/unit/memo-multipush.test.js @@ -0,0 +1,123 @@ +/* + Unit tests for the multi-push OP_RETURN adapter. + + Memo actions that carry more than one field must encode each field as its + own OP_RETURN script push. minimal-slp-wallet broadcasts a single msg push, + so the adapter wraps the wallet's sendOpReturn: an array first argument is + expanded to separate pushes, while single-field calls keep delegating to the + original wallet method. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { + toPushBuffer, + buildPushes, + attachMultiPushOpReturn +} = require('../../src/services/memo-multipush') + +// Encode a script the way Bitcoin does: an opcode number is one byte, a +// Buffer/string becomes a length-prefixed push. Enough to observe the pushes. +function encodeScript (script) { + const parts = script.map((el) => { + if (typeof el === 'number') return Buffer.from([el]) + const buf = Buffer.from(el) + return Buffer.concat([Buffer.from([buf.length]), buf]) + }) + return Buffer.concat(parts) +} + +// A double of the minimal-slp-wallet surface the adapter reuses. +function makeSlpWalletDouble () { + const sent = [] + const wallet = { + walletInfo: { cashAddress: 'bitcoincash:qtest' }, + fee: 1, + walletInfoPromise: Promise.resolve(), + utxos: { utxoStore: { bchUtxos: [{ tx_hash: 'aa', tx_pos: 0, value: 100000 }] } }, + calls: [], + async sendOpReturn (msg, prefix, bchOutput = []) { + this.calls.push({ msg, prefix, bchOutput }) + return 'single-txid' + }, + opReturn: { + bchjs: { + Script: { + opcodes: { OP_RETURN: 0x6a }, + encode2: encodeScript + } + }, + async createTransaction (walletInfo, bchUtxos, msg, prefix) { + this.lastScript = [ + this.bchjs.Script.opcodes.OP_RETURN, + Buffer.from(prefix, 'hex'), + Buffer.from(msg) + ] + this.lastEncoded = this.bchjs.Script.encode2(this.lastScript) + return { hex: 'beef' } + }, + ar: { + async sendTx (hex) { + sent.push(hex) + return 'txid-1' + } + } + } + } + return { wallet, sent } +} + +test('toPushBuffer encodes a string as UTF-8 and passes bytes through', () => { + assert.equal(toPushBuffer('hi').toString('hex'), '6869') + assert.equal(toPushBuffer(Uint8Array.from([1, 2])).toString('hex'), '0102') +}) + +test('buildPushes puts the prefix first and each field in order', () => { + const pushes = buildPushes('6d03', [Buffer.from('aa', 'hex'), 'hi']) + + assert.equal(pushes.length, 3) + assert.equal(pushes[0].toString('hex'), '6d03') + assert.equal(pushes[1].toString('hex'), 'aa') + assert.equal(pushes[2].toString('utf8'), 'hi') +}) + +test('array fields broadcast as separate OP_RETURN pushes', async () => { + const { wallet, sent } = makeSlpWalletDouble() + attachMultiPushOpReturn(wallet) + + const txid = await wallet.sendOpReturn( + [Buffer.from('aabb', 'hex'), Buffer.from('hello')], + '6d03' + ) + + assert.equal(txid, 'txid-1') + assert.deepEqual(sent, ['beef']) + // OP_RETURN, push(6d03), push(aabb), push(hello) — each its own push. + assert.equal( + wallet.opReturn.lastEncoded.toString('hex'), + '6a026d0302aabb0568656c6c6f' + ) +}) + +test('a single field delegates to the original wallet sendOpReturn', async () => { + const { wallet } = makeSlpWalletDouble() + attachMultiPushOpReturn(wallet) + + const txid = await wallet.sendOpReturn('single', '6d02', []) + + assert.equal(txid, 'single-txid') + assert.equal(wallet.calls.length, 1) + assert.equal(wallet.calls[0].msg, 'single') +}) + +test('attaching twice does not double-wrap the wallet', async () => { + const { wallet } = makeSlpWalletDouble() + attachMultiPushOpReturn(wallet) + attachMultiPushOpReturn(wallet) + + await wallet.sendOpReturn('single', '6d02') + + assert.equal(wallet.calls.length, 1) +}) diff --git a/psf-memo-client/test/unit/memo-poll-create.test.js b/psf-memo-client/test/unit/memo-poll-create.test.js index 10aaf26..c8a20bc 100644 --- a/psf-memo-client/test/unit/memo-poll-create.test.js +++ b/psf-memo-client/test/unit/memo-poll-create.test.js @@ -28,16 +28,7 @@ function makeWallet (address = MY_ADDRESS) { } } -function decodePayload (raw) { - const buf = Buffer.from(raw) - return { - pollType: buf[0], - optionCount: buf[1], - question: buf.slice(2).toString('utf8') - } -} - -test('create broadcasts with the create-poll prefix and payload', async () => { +test('create broadcasts the poll type, option count, and question as separate pushes', async () => { const wallet = makeWallet() const memoPollCreate = new MemoPollCreate({ wallet }) @@ -45,10 +36,12 @@ test('create broadcasts with the create-poll prefix and payload', async () => { assert.equal(wallet.broadcasts.length, 1) assert.equal(wallet.broadcasts[0].prefix, MemoPollCreate.MEMO_CREATE_POLL_PREFIX) - const decoded = decodePayload(wallet.broadcasts[0].msg) - assert.equal(decoded.question, 'which is better?') - assert.equal(decoded.optionCount, 2) - assert.equal(decoded.pollType, 1) + const pushes = wallet.broadcasts[0].msg + assert.ok(Array.isArray(pushes), 'expected separate pushes') + assert.equal(pushes.length, 3) + assert.equal(Buffer.from(pushes[0])[0], 1) + assert.equal(Buffer.from(pushes[1])[0], 2) + assert.equal(Buffer.from(pushes[2]).toString('utf8'), 'which is better?') }) test('create accepts an option count of one', async () => { diff --git a/psf-memo-client/test/unit/memo-poll-option.test.js b/psf-memo-client/test/unit/memo-poll-option.test.js index ded56f9..1c0d983 100644 --- a/psf-memo-client/test/unit/memo-poll-option.test.js +++ b/psf-memo-client/test/unit/memo-poll-option.test.js @@ -30,14 +30,7 @@ function makeWallet (address = MY_ADDRESS) { } } -function decodePayload (raw) { - const buf = Buffer.from(raw) - const pollTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex') - const option = buf.slice(32).toString('utf8') - return { pollTxid, option } -} - -test('add broadcasts with the add-poll-option prefix and payload', async () => { +test('add broadcasts the poll txid and option as separate pushes', async () => { const wallet = makeWallet() const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID }) @@ -45,9 +38,11 @@ test('add broadcasts with the add-poll-option prefix and payload', async () => { assert.equal(wallet.broadcasts.length, 1) assert.equal(wallet.broadcasts[0].prefix, MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX) - const decoded = decodePayload(wallet.broadcasts[0].msg) - assert.equal(decoded.pollTxid, POLL_TXID) - assert.equal(decoded.option, 'yes') + const pushes = wallet.broadcasts[0].msg + assert.ok(Array.isArray(pushes), 'expected separate pushes') + assert.equal(pushes.length, 2) + assert.equal(Buffer.from(pushes[0]).reverse().toString('hex'), POLL_TXID) + assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'yes') }) test('add reflects the new option on the injected poll store', async () => { diff --git a/psf-memo-client/test/unit/memo-poll-vote.test.js b/psf-memo-client/test/unit/memo-poll-vote.test.js index 7c4674f..485b465 100644 --- a/psf-memo-client/test/unit/memo-poll-vote.test.js +++ b/psf-memo-client/test/unit/memo-poll-vote.test.js @@ -30,14 +30,7 @@ function makeWallet (address = MY_ADDRESS) { } } -function decodePayload (raw) { - const buf = Buffer.from(raw) - const pollTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex') - const comment = buf.slice(32).toString('utf8') - return { pollTxid, comment } -} - -test('vote broadcasts with the poll-vote prefix and payload', async () => { +test('vote broadcasts the poll txid and comment as separate pushes', async () => { const wallet = makeWallet() const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID }) @@ -45,9 +38,11 @@ test('vote broadcasts with the poll-vote prefix and payload', async () => { assert.equal(wallet.broadcasts.length, 1) assert.equal(wallet.broadcasts[0].prefix, MemoPollVote.MEMO_POLL_VOTE_PREFIX) - const decoded = decodePayload(wallet.broadcasts[0].msg) - assert.equal(decoded.pollTxid, POLL_TXID) - assert.equal(decoded.comment, 'yes') + const pushes = wallet.broadcasts[0].msg + assert.ok(Array.isArray(pushes), 'expected separate pushes') + assert.equal(pushes.length, 2) + assert.equal(Buffer.from(pushes[0]).reverse().toString('hex'), POLL_TXID) + assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'yes') }) test('vote reflects the new vote on the injected poll store', async () => { diff --git a/psf-memo-client/test/unit/memo-reply.test.js b/psf-memo-client/test/unit/memo-reply.test.js index 9109584..4e26668 100644 --- a/psf-memo-client/test/unit/memo-reply.test.js +++ b/psf-memo-client/test/unit/memo-reply.test.js @@ -31,7 +31,7 @@ function makeWallet () { } } -test('reply broadcasts the parent txid in little-endian wire order', async () => { +test('reply broadcasts the parent txid and text as separate pushes', async () => { const wallet = makeWallet() const memoReply = new MemoReply({ wallet }) @@ -39,9 +39,11 @@ test('reply broadcasts the parent txid in little-endian wire order', async () => assert.equal(wallet.broadcasts.length, 1) assert.equal(wallet.broadcasts[0].prefix, MemoReply.MEMO_REPLY_PREFIX) - const buf = Buffer.from(wallet.broadcasts[0].msg) - assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX) - assert.equal(buf.slice(32).toString('utf8'), 'hello memo') + const pushes = wallet.broadcasts[0].msg + assert.ok(Array.isArray(pushes), 'expected separate pushes') + assert.equal(pushes.length, 2) + assert.equal(Buffer.from(pushes[0]).toString('hex'), WIRE_HEX) + assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hello memo') }) test('isTooLong accepts a message exactly at the byte limit', () => { diff --git a/psf-memo-client/test/unit/memo-topic-post.test.js b/psf-memo-client/test/unit/memo-topic-post.test.js index a6f21f4..823b0ba 100644 --- a/psf-memo-client/test/unit/memo-topic-post.test.js +++ b/psf-memo-client/test/unit/memo-topic-post.test.js @@ -26,7 +26,7 @@ function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26 } } -test('post broadcasts topic name and message with the topic-message prefix', async () => { +test('post broadcasts topic name and message as separate pushes', async () => { const wallet = makeWallet() const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' }) @@ -34,7 +34,11 @@ test('post broadcasts topic name and message with the topic-message prefix', asy assert.equal(wallet.broadcasts.length, 1) assert.equal(wallet.broadcasts[0].prefix, MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX) - assert.equal(wallet.broadcasts[0].msg, 'bitcoinhello bitcoin') + const pushes = wallet.broadcasts[0].msg + assert.ok(Array.isArray(pushes), 'expected separate pushes') + assert.equal(pushes.length, 2) + assert.equal(Buffer.from(pushes[0]).toString('utf8'), 'bitcoin') + assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hello bitcoin') }) test('post reflects the new topic post on the injected feed', async () => { From 8310b6a1d85271ed6738b78dbdc9e35ad0cbf3af Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 20:56:42 -0700 Subject: [PATCH 2/5] Refactor multi-push helpers and add adapter property tests Share a single UTF-8 encoding helper across hex, poll-create, and topic-post instead of repeating new TextEncoder().encode(), drop the redundant Uint8Array branch in the push normalizer, rename the txid-action push builder parameter from buildPayload to buildPushes to match what it returns, and factor the db repair unit-test setup into a repairedFixture helper. Add property tests for the multi-push adapter covering prefix/ordering, field-byte conservation, buffer round trips, array expansion, single field delegation, and idempotent attach, plus a unit test for the unsupported-wallet guard. By refactorer. --- psf-memo-client/src/services/hex.js | 4 +- .../src/services/memo-multipush.js | 1 - .../src/services/memo-poll-create.js | 4 +- .../src/services/memo-topic-post.js | 6 +- .../src/services/memo-txid-action.js | 9 +- psf-memo-client/src/services/utf8.js | 14 +- .../property/memo-multipush.property.test.js | 182 ++++++++++++++++++ .../test/unit/memo-multipush.test.js | 8 + .../unit/lib/repair-txid-encoding.unit.js | 39 ++-- 9 files changed, 228 insertions(+), 39 deletions(-) create mode 100644 psf-memo-client/test/property/memo-multipush.property.test.js diff --git a/psf-memo-client/src/services/hex.js b/psf-memo-client/src/services/hex.js index be2ad84..f78412e 100644 --- a/psf-memo-client/src/services/hex.js +++ b/psf-memo-client/src/services/hex.js @@ -7,6 +7,8 @@ testable conversion helper. */ +const { encodeUtf8 } = require('./utf8') + // Decode a hex string into a Uint8Array of the requested byte length. // The label parameter customizes error messages for the caller's context. function hexToBytes (hex, byteLength = 32, label = 'Value') { @@ -38,7 +40,7 @@ function txidToWireBytes (txid, label = 'Value') { // each as its own push. The label customizes the invalid-txid error message. function buildTxidTextPushes (txid, text, label = 'Poll txid') { const txidBytes = txidToWireBytes(txid, label) - const textBytes = new TextEncoder().encode(text) + const textBytes = encodeUtf8(text) return [txidBytes, textBytes] } diff --git a/psf-memo-client/src/services/memo-multipush.js b/psf-memo-client/src/services/memo-multipush.js index 6f0f5fe..8ec77d2 100644 --- a/psf-memo-client/src/services/memo-multipush.js +++ b/psf-memo-client/src/services/memo-multipush.js @@ -18,7 +18,6 @@ // Normalize one push value (a UTF-8 string or byte array) to a Buffer. function toPushBuffer (value) { if (typeof value === 'string') return Buffer.from(value, 'utf8') - if (value instanceof Uint8Array) return Buffer.from(value) return Buffer.from(value) } diff --git a/psf-memo-client/src/services/memo-poll-create.js b/psf-memo-client/src/services/memo-poll-create.js index aae56f5..4da2d34 100644 --- a/psf-memo-client/src/services/memo-poll-create.js +++ b/psf-memo-client/src/services/memo-poll-create.js @@ -17,7 +17,7 @@ */ const MemoAction = require('./memo-action') -const { byteLength } = require('./utf8') +const { byteLength, encodeUtf8 } = require('./utf8') const MEMO_CREATE_POLL_PREFIX = '6d10' const MAX_QUESTION_BYTES = 209 @@ -87,7 +87,7 @@ class MemoPollCreate extends MemoAction { // Build the separate OP_RETURN pushes for a create-poll action: the poll type // byte, the option count byte, and the UTF-8 question, each as its own push. function buildCreatePollPushes (question, pollType, optionCount) { - const textBytes = new TextEncoder().encode(question) + const textBytes = encodeUtf8(question) return [ Uint8Array.from([pollType & 0xff]), Uint8Array.from([optionCount & 0xff]), diff --git a/psf-memo-client/src/services/memo-topic-post.js b/psf-memo-client/src/services/memo-topic-post.js index 01f2848..ab9802f 100644 --- a/psf-memo-client/src/services/memo-topic-post.js +++ b/psf-memo-client/src/services/memo-topic-post.js @@ -16,7 +16,7 @@ */ const MemoAction = require('./memo-action') -const { byteLength } = require('./utf8') +const { byteLength, encodeUtf8 } = require('./utf8') const MEMO_TOPIC_MESSAGE_PREFIX = '6d0c' const MAX_TOPIC_MESSAGE_BYTES = 214 @@ -59,8 +59,8 @@ class MemoTopicPost extends MemoAction { await this.wallet.getUtxos() const pushes = [ - new TextEncoder().encode(this.room), - new TextEncoder().encode(message) + encodeUtf8(this.room), + encodeUtf8(message) ] const txid = await this.wallet.sendOpReturn(pushes, this.prefix) diff --git a/psf-memo-client/src/services/memo-txid-action.js b/psf-memo-client/src/services/memo-txid-action.js index 4d659e2..b72d65c 100644 --- a/psf-memo-client/src/services/memo-txid-action.js +++ b/psf-memo-client/src/services/memo-txid-action.js @@ -22,8 +22,9 @@ class MemoTxidAction extends MemoAction { } // Compose and broadcast an action that embeds this.pollTxid plus the given - // value through the supplied buildPayload(pollTxid, value) function. - async broadcastTxid (value, buildPayload) { + // value. buildPushes(pollTxid, value) returns the ordered OP_RETURN field + // pushes for the action. + async broadcastTxid (value, buildPushes) { const check = this.validate(value) this._throwIfInvalid(check) @@ -39,8 +40,8 @@ class MemoTxidAction extends MemoAction { await this.wallet.getUtxos() - const raw = buildPayload(this.pollTxid, value) - const txid = await this.wallet.sendOpReturn(raw, this.prefix) + const fields = buildPushes(this.pollTxid, value) + const txid = await this.wallet.sendOpReturn(fields, this.prefix) this.reflect(txid, value) diff --git a/psf-memo-client/src/services/utf8.js b/psf-memo-client/src/services/utf8.js index 8ceee9e..dcbb0b0 100644 --- a/psf-memo-client/src/services/utf8.js +++ b/psf-memo-client/src/services/utf8.js @@ -7,12 +7,18 @@ length of a string. */ -// Return the number of UTF-8 bytes in a string. -function byteLength (str) { - return new TextEncoder().encode(String(str)).length +// Encode a string as UTF-8 bytes. TextEncoder is available in both the +// browser and Node. +function encodeUtf8 (str) { + return new TextEncoder().encode(String(str)) } -module.exports = { byteLength } +// Return the number of UTF-8 bytes in a string. +function byteLength (str) { + return encodeUtf8(str).length +} + +module.exports = { encodeUtf8, byteLength } // mutate4javascript-manifest-begin // {"version":1,"tested_at":"2026-08-26T12:17:55.885Z","module_hash":"7f91541c49f2b6f421e8d4158bfe808b35a5449534cb26c567162fce6fec64bf","functions":[{"id":"func/byteLength","name":"byteLength","line":11,"end_line":13,"hash":"973c9dadcd1d8bbd53587252443880db13c8be3639fa74ce3c69a08ea358c8e2"}]} diff --git a/psf-memo-client/test/property/memo-multipush.property.test.js b/psf-memo-client/test/property/memo-multipush.property.test.js new file mode 100644 index 0000000..e5cde2a --- /dev/null +++ b/psf-memo-client/test/property/memo-multipush.property.test.js @@ -0,0 +1,182 @@ +/* + Property tests for the multi-push OP_RETURN adapter. + + Unit tests pin the adapter's push expansion at fixed fixtures. These + properties pin the encoding invariants over broad random field lists: + + - Ordering and prefix: buildPushes always returns the action prefix first + followed by one push per field, in input order. + - Conservation: the concatenation of the field pushes equals the UTF-8 + encoding of the input fields, with no bytes added or dropped. + - Round trip: toPushBuffer encodes a string as its UTF-8 bytes and passes + byte arrays through unchanged. + - Dispatch and idempotence: the attached wallet expands an array argument + to separate pushes, delegates a single-field call to the original + method, and attaching twice does not double-wrap. +*/ + +'use strict' + +const test = require('node:test') +const { seededRandom, forAll, intGen } = require('./harness') +const { + toPushBuffer, + buildPushes, + attachMultiPushOpReturn +} = require('../../src/services/memo-multipush') + +const rng = seededRandom(20260916) + +// Characters with distinct UTF-8 byte widths: 1, 1, 2, 3, and 4 bytes. +const CHARS = ['a', 'b', ' ', '\u00e9', '\u20ac', '\ud83d\ude00'] + +function randomPrefix () { + const len = 2 * intGen(rng, 1, 4)() + const hex = '0123456789abcdef' + let out = '' + for (let i = 0; i < len; i++) out += hex[Math.floor(rng() * 16)] + return out +} + +function randomString () { + const len = intGen(rng, 0, 24)() + let out = '' + for (let i = 0; i < len; i++) out += CHARS[Math.floor(rng() * CHARS.length)] + return out +} + +// Half UTF-8 strings, half raw byte arrays. +function randomField () { + if (rng() < 0.5) return randomString() + const len = intGen(rng, 0, 8)() + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) bytes[i] = intGen(rng, 0, 255)() + return bytes +} + +function randomFields () { + const count = intGen(rng, 1, 5)() + const fields = [] + for (let i = 0; i < count; i++) fields.push(randomField()) + return fields +} + +// Encode a script the way Bitcoin does: an opcode number is one byte, a +// Buffer becomes a length-prefixed push. +function encodeScript (script) { + const parts = script.map((el) => { + if (typeof el === 'number') return Buffer.from([el]) + const buf = Buffer.from(el) + return Buffer.concat([Buffer.from([buf.length]), buf]) + }) + return Buffer.concat(parts) +} + +// A double of the minimal-slp-wallet surface the adapter reuses. +function makeWalletDouble () { + const sent = [] + const wallet = { + walletInfo: { cashAddress: 'bitcoincash:qtest' }, + fee: 1, + walletInfoPromise: Promise.resolve(), + utxos: { utxoStore: { bchUtxos: [{ tx_hash: 'aa', tx_pos: 0, value: 100000 }] } }, + calls: [], + async sendOpReturn (msg, prefix, bchOutput = []) { + this.calls.push({ msg, prefix, bchOutput }) + return 'single-txid' + }, + opReturn: { + bchjs: { + Script: { opcodes: { OP_RETURN: 0x6a }, encode2: encodeScript } + }, + async createTransaction (walletInfo, bchUtxos, msg, prefix) { + this.lastEncoded = this.bchjs.Script.encode2([ + this.bchjs.Script.opcodes.OP_RETURN, + Buffer.from(prefix, 'hex'), + Buffer.from(msg) + ]) + return { hex: 'beef' } + }, + ar: { + async sendTx (hex) { + sent.push(hex) + return 'txid-1' + } + } + } + } + return { wallet, sent } +} + +test('buildPushes keeps the prefix first and the fields in order', async () => { + await forAll( + () => ({ prefix: randomPrefix(), fields: randomFields() }), + ({ prefix, fields }) => { + const pushes = buildPushes(prefix, fields) + if (pushes.length !== fields.length + 1) return false + if (pushes[0].toString('hex') !== prefix) return false + for (let i = 0; i < fields.length; i++) { + if (!pushes[i + 1].equals(toPushBuffer(fields[i]))) return false + } + return true + }, + { label: 'buildPushes ordering and prefix' } + ) +}) + +test('buildPushes conserves the field bytes', async () => { + await forAll( + () => ({ prefix: randomPrefix(), fields: randomFields() }), + ({ prefix, fields }) => { + const pushes = buildPushes(prefix, fields) + const actual = Buffer.concat(pushes.slice(1)) + const expected = Buffer.concat(fields.map((field) => toPushBuffer(field))) + return actual.equals(expected) + }, + { label: 'buildPushes conservation' } + ) +}) + +test('toPushBuffer round-trips strings and byte arrays', async () => { + await forAll( + () => randomField(), + (field) => { + const buf = toPushBuffer(field) + if (typeof field === 'string') return buf.toString('utf8') === field + return buf.equals(Buffer.from(field)) + }, + { label: 'toPushBuffer round trip' } + ) +}) + +test('an attached wallet expands arrays into separate pushes', async () => { + await forAll( + () => ({ prefix: randomPrefix(), fields: randomFields() }), + async ({ prefix, fields }) => { + const { wallet } = makeWalletDouble() + attachMultiPushOpReturn(wallet) + const txid = await wallet.sendOpReturn(fields, prefix) + if (txid !== 'txid-1') return false + const expected = encodeScript([ + wallet.opReturn.bchjs.Script.opcodes.OP_RETURN, + ...buildPushes(prefix, fields) + ]) + return wallet.opReturn.lastEncoded.equals(expected) + }, + { label: 'adapter multi-push expansion' } + ) +}) + +test('an attached wallet delegates a single field and attaches only once', async () => { + await forAll( + () => randomString(), + async (text) => { + const { wallet } = makeWalletDouble() + attachMultiPushOpReturn(wallet) + attachMultiPushOpReturn(wallet) + const txid = await wallet.sendOpReturn(text, '6d02') + return txid === 'single-txid' && wallet.calls.length === 1 && wallet.calls[0].msg === text + }, + { label: 'adapter delegation and idempotent attach' } + ) +}) diff --git a/psf-memo-client/test/unit/memo-multipush.test.js b/psf-memo-client/test/unit/memo-multipush.test.js index b8b9fd3..7d76835 100644 --- a/psf-memo-client/test/unit/memo-multipush.test.js +++ b/psf-memo-client/test/unit/memo-multipush.test.js @@ -15,6 +15,7 @@ const assert = require('node:assert/strict') const { toPushBuffer, buildPushes, + broadcastMultiPush, attachMultiPushOpReturn } = require('../../src/services/memo-multipush') @@ -121,3 +122,10 @@ test('attaching twice does not double-wrap the wallet', async () => { assert.equal(wallet.calls.length, 1) }) + +test('broadcastMultiPush rejects a wallet without the OP_RETURN builder', async () => { + await assert.rejects( + broadcastMultiPush({ opReturn: {} }, ['hi'], '6d02'), + /does not support multi-push OP_RETURN broadcasts/ + ) +}) diff --git a/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js b/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js index 7b4622e..0e62641 100644 --- a/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js +++ b/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js @@ -44,6 +44,15 @@ function loadReversedFixture (level) { level.pollVotesDb.put('vote-2', { pollTxid: DISPLAY_POLL, comment: 'no', blockHeight: 11 }) } +// Load the reversed-reference fixture, run the repair once, and return the +// repaired level for assertions. +async function repairedFixture () { + const level = makeLevel() + loadReversedFixture(level) + await repairTxidEncoding(level) + return level +} + describe('#RepairTxidEncoding', () => { describe('reverseTxid', () => { it('reverses the byte order of a display txid', () => { @@ -98,10 +107,7 @@ describe('#RepairTxidEncoding', () => { describe('repairTxidEncoding', () => { it('corrects a reversed like reference and rebuilds postLikes', async () => { - const level = makeLevel() - loadReversedFixture(level) - - await repairTxidEncoding(level) + const level = await repairedFixture() assert.equal((await level.likesDb.get('like-1')).postTxid, DISPLAY_POST) assert.include(level.postLikesDb.keys(), `${DISPLAY_POST}:like-1`) @@ -109,10 +115,7 @@ describe('#RepairTxidEncoding', () => { }) it('corrects a reversed reply reference and rebuilds postChildren', async () => { - const level = makeLevel() - loadReversedFixture(level) - - await repairTxidEncoding(level) + const level = await repairedFixture() assert.equal((await level.postParentsDb.get('reply-1')).parentTxid, DISPLAY_POST) assert.include(level.postChildrenDb.keys(), `${DISPLAY_POST}:reply-1`) @@ -120,20 +123,14 @@ describe('#RepairTxidEncoding', () => { }) it('corrects reversed poll option and vote references', async () => { - const level = makeLevel() - loadReversedFixture(level) - - await repairTxidEncoding(level) + const level = await repairedFixture() assert.equal((await level.pollOptionsDb.get('option-1')).pollTxid, DISPLAY_POLL) assert.equal((await level.pollVotesDb.get('vote-1')).pollTxid, DISPLAY_POLL) }) it('leaves correctly-encoded references unchanged', async () => { - const level = makeLevel() - loadReversedFixture(level) - - await repairTxidEncoding(level) + const level = await repairedFixture() assert.equal((await level.likesDb.get('like-2')).postTxid, DISPLAY_POST) assert.equal((await level.postParentsDb.get('reply-2')).parentTxid, DISPLAY_POST) @@ -142,10 +139,7 @@ describe('#RepairTxidEncoding', () => { }) it('leaves an unknown reference unchanged', async () => { - const level = makeLevel() - loadReversedFixture(level) - - await repairTxidEncoding(level) + const level = await repairedFixture() assert.equal((await level.likesDb.get('like-3')).postTxid, UNKNOWN) }) @@ -163,10 +157,7 @@ describe('#RepairTxidEncoding', () => { }) it('is idempotent', async () => { - const level = makeLevel() - loadReversedFixture(level) - - await repairTxidEncoding(level) + const level = await repairedFixture() await repairTxidEncoding(level) const postLikes = level.postLikesDb.keys() From fac01730c4beaf5b412302f919e47871778f4d08 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 21:31:23 -0700 Subject: [PATCH 3/5] Review multi-push encoding: fix browser Buffer and harden coverage Import the browser-safe buffer module in the multi-push adapter so the CRA production bundle no longer reads the Node global Buffer, add regression tests for the falsy-wallet and idempotent-attach paths, share the script-encoding test helper, and de-duplicate the UTF-8 push acceptance assertions. Refresh the language mutation manifests and the soft acceptance-mutation manifest. By architect. --- psf-memo-client/acceptance/lib/handlers.js | 28 +++++++------ psf-memo-client/package-lock.json | 6 +-- psf-memo-client/package.json | 1 + .../specs/memo-multipush-encoding.feature | 4 ++ psf-memo-client/src/services/hex.js | 2 +- .../src/services/memo-multipush.js | 11 ++++++ .../src/services/memo-poll-create.js | 2 +- .../src/services/memo-poll-option.js | 2 +- .../src/services/memo-poll-vote.js | 2 +- psf-memo-client/src/services/memo-reply.js | 2 +- .../src/services/memo-topic-post.js | 2 +- .../src/services/memo-txid-action.js | 2 +- psf-memo-client/src/services/utf8.js | 2 +- .../property/memo-multipush.property.test.js | 12 +----- .../test/support/script-encoding.js | 20 ++++++++++ .../test/unit/memo-multipush.test.js | 39 +++++++++++++------ 16 files changed, 89 insertions(+), 48 deletions(-) create mode 100644 psf-memo-client/test/support/script-encoding.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index a251274..bec92cb 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -589,6 +589,16 @@ function txidWireHex (txid) { return Buffer.from(txid, 'hex').reverse().toString('hex') } +// Assert that the most recent broadcast's push at `index` decodes to the +// expected UTF-8 text. Shared by the topic/text/question push assertions. +function assertUtf8Push (world, index, expected, label) { + const push = broadcastPush(world, index) + const actual = push.toString('utf8') + if (actual !== expected) { + throw new Error(`${label} push "${actual}" did not match "${expected}".`) + } +} + // Resolve a literal value or a placeholder from the example store. function resolveParam (value, example) { const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim()) @@ -1113,11 +1123,7 @@ const handlers = [ name: 'second broadcast push is UTF-8 topic', pattern: /^the second broadcast push is the UTF-8 topic "(.+)"$/, run (m, example, world) { - const topic = resolveText(m[1], example) - const push = broadcastPush(world, 1) - if (push.toString('utf8') !== topic) { - throw new Error(`Second push "${push.toString('utf8')}" did not match topic "${topic}".`) - } + assertUtf8Push(world, 1, resolveText(m[1], example), 'Second') } }, { @@ -1135,11 +1141,7 @@ const handlers = [ name: 'third broadcast push is UTF-8 text', pattern: /^the third broadcast push is the UTF-8 text "(.+)"$/, run (m, example, world) { - const text = resolveText(m[1], example) - const push = broadcastPush(world, 2) - if (push.toString('utf8') !== text) { - throw new Error(`Third push "${push.toString('utf8')}" did not match text "${text}".`) - } + assertUtf8Push(world, 2, resolveText(m[1], example), 'Third') } }, { @@ -1157,11 +1159,7 @@ const handlers = [ name: 'fourth broadcast push is UTF-8 question', pattern: /^the fourth broadcast push is the UTF-8 question "(.+)"$/, run (m, example, world) { - const question = resolveText(m[1], example) - const push = broadcastPush(world, 3) - if (push.toString('utf8') !== question) { - throw new Error(`Fourth push "${push.toString('utf8')}" did not match question "${question}".`) - } + assertUtf8Push(world, 3, resolveText(m[1], example), 'Fourth') } }, { diff --git a/psf-memo-client/package-lock.json b/psf-memo-client/package-lock.json index 24d490c..5e0ea2e 100644 --- a/psf-memo-client/package-lock.json +++ b/psf-memo-client/package-lock.json @@ -17,6 +17,7 @@ "bch-message-lib": "2.2.1", "bch-token-sweep": "2.2.1", "bootstrap": "5.2.0", + "buffer": "^6.0.3", "qrcode.react": "4.2.0", "query-string": "7.1.1", "react": "19.0.0", @@ -6160,7 +6161,6 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "dev": true, "funding": [ { "type": "github", @@ -6637,7 +6637,8 @@ }, "node_modules/buffer": { "version": "6.0.3", - "dev": true, + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -11703,7 +11704,6 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", diff --git a/psf-memo-client/package.json b/psf-memo-client/package.json index c16cc04..6fe1bf4 100644 --- a/psf-memo-client/package.json +++ b/psf-memo-client/package.json @@ -11,6 +11,7 @@ "bch-message-lib": "2.2.1", "bch-token-sweep": "2.2.1", "bootstrap": "5.2.0", + "buffer": "^6.0.3", "qrcode.react": "4.2.0", "query-string": "7.1.1", "react": "19.0.0", diff --git a/psf-memo-client/specs/memo-multipush-encoding.feature b/psf-memo-client/specs/memo-multipush-encoding.feature index 84a339a..1f23de6 100644 --- a/psf-memo-client/specs/memo-multipush-encoding.feature +++ b/psf-memo-client/specs/memo-multipush-encoding.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-17T04:24:27.366008662Z","feature_name":"Memo multi-push encoding","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/memo-multipush-encoding.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[]} +# acceptance-mutation-manifest-end + # Scenarios: Memo multi-push encoding - 1, Memo multi-push encoding - 2, Memo multi-push encoding - 3, Memo multi-push encoding - 4, Memo multi-push encoding - 5 # # Memo actions that carry more than one field encode each protocol field as diff --git a/psf-memo-client/src/services/hex.js b/psf-memo-client/src/services/hex.js index f78412e..617311d 100644 --- a/psf-memo-client/src/services/hex.js +++ b/psf-memo-client/src/services/hex.js @@ -47,5 +47,5 @@ function buildTxidTextPushes (txid, text, label = 'Poll txid') { module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPushes } // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-16T22:01:27.280Z","module_hash":"454b45f3ce08c5ea6346c113db6384f7a44dddbd21e2fb0a35c41ce6701e62c8","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":12,"end_line":26,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/txidToWireBytes","name":"txidToWireBytes","line":32,"end_line":34,"hash":"ac25753fecb32c929134b6e6379cae0bfddd4aa6f7ae438a08da650450e9ea1a"},{"id":"func/buildTxidTextPayload","name":"buildTxidTextPayload","line":39,"end_line":46,"hash":"068504ea13a037adabbbfeab122fb5ec6113625a9e0b009cbbe8b46b6bebdaee"}]} +// {"version":1,"tested_at":"2026-09-17T04:10:11.935Z","module_hash":"e3a608fad7d52d78c2c94a873161d6085c9afc2326a07fa04eb8a67815ed362a","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":14,"end_line":28,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/txidToWireBytes","name":"txidToWireBytes","line":34,"end_line":36,"hash":"ac25753fecb32c929134b6e6379cae0bfddd4aa6f7ae438a08da650450e9ea1a"},{"id":"func/buildTxidTextPushes","name":"buildTxidTextPushes","line":41,"end_line":45,"hash":"815887680e60a7ed823b15fe6cc25b16f931e4390e37dd89aee409cf55836fd0"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-multipush.js b/psf-memo-client/src/services/memo-multipush.js index 8ec77d2..28e3982 100644 --- a/psf-memo-client/src/services/memo-multipush.js +++ b/psf-memo-client/src/services/memo-multipush.js @@ -15,6 +15,13 @@ the wallet wiring is the small environmentally unsuitable boundary. */ +// Import the browser-compatible Buffer implementation. `Buffer` is a Node +// global, but the CRA production bundle does not polyfill Node globals and the +// wallet script does not define window.Buffer, so a bare global would throw in +// the browser. The npm buffer package is the same implementation the bundled +// bitcoincashjs-lib uses, so its Buffer.isBuffer accepts these pushes. +const { Buffer } = require('buffer') + // Normalize one push value (a UTF-8 string or byte array) to a Buffer. function toPushBuffer (value) { if (typeof value === 'string') return Buffer.from(value, 'utf8') @@ -86,3 +93,7 @@ module.exports = { broadcastMultiPush, attachMultiPushOpReturn } + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-17T04:08:42.622Z","module_hash":"9bce63e69187b0a42ba210437ea14a32fdd7f53ccfd905264375bba997a3a4c3","functions":[{"id":"func/toPushBuffer","name":"toPushBuffer","line":26,"end_line":29,"hash":"0ff6744e881d65ce60d6456df450f5f0f26a39492d387b2b33aefec8c12e2e81"},{"id":"func/buildPushes","name":"buildPushes","line":33,"end_line":35,"hash":"46961294949a914dbab5064b1b2fcf5c3ba59c2e438b2a986275d3dfed40ea71"},{"id":"func/broadcastMultiPush","name":"broadcastMultiPush","line":40,"end_line":73,"hash":"ee5022606ea152763159d7fd9753c3849b723c5a8ab1cd1cb399feb4248479d6"},{"id":"func/attachMultiPushOpReturn","name":"attachMultiPushOpReturn","line":77,"end_line":88,"hash":"ef81a1526f376fc1ad500d72dafdd684d78ad8052202e938a39654bb182e8eeb"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-poll-create.js b/psf-memo-client/src/services/memo-poll-create.js index 4da2d34..7278709 100644 --- a/psf-memo-client/src/services/memo-poll-create.js +++ b/psf-memo-client/src/services/memo-poll-create.js @@ -102,5 +102,5 @@ MemoPollCreate.DEFAULT_POLL_TYPE = DEFAULT_POLL_TYPE module.exports = MemoPollCreate // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-28T22:45:49.496Z","module_hash":"3455852bee199b40530bbbe787f85429a7a15bf0116989231ac06f9ca9eadf36","functions":[{"id":"func/MemoPollCreate.constructor","name":"MemoPollCreate.constructor","line":36,"end_line":40,"hash":"9404cf1f0df5cc8756e0ea24abab27ade844fa461f84818162faac807b838dd1"},{"id":"func/MemoPollCreate.isTooLong","name":"MemoPollCreate.isTooLong","line":43,"end_line":45,"hash":"ca9087a454f1fba64ac35738a037372ade36ba6d4c2270d2025adc00b860412c"},{"id":"func/MemoPollCreate.create","name":"MemoPollCreate.create","line":48,"end_line":71,"hash":"b5e769b07f3795a5db0d4202106f4b25b5a0b69e30e70491a7a9c2ac52085fce"},{"id":"func/MemoPollCreate.reflect","name":"MemoPollCreate.reflect","line":74,"end_line":84,"hash":"ec559b778b279c1efb46bdb2440a0c197dae242052d7c1ebf5331078258b719f"},{"id":"func/buildCreatePollPayload","name":"buildCreatePollPayload","line":89,"end_line":96,"hash":"f5abdaad00f9e3c857c65766a981d6971e1bc7c64e0200cbb4105ab798bd94da"}]} +// {"version":1,"tested_at":"2026-09-17T04:15:33.060Z","module_hash":"f768a640a3cf8aff7dd9836bcbed38ca2d2fa6892cca97f8d078f4ad5d9d5287","functions":[{"id":"func/MemoPollCreate.constructor","name":"MemoPollCreate.constructor","line":36,"end_line":40,"hash":"9404cf1f0df5cc8756e0ea24abab27ade844fa461f84818162faac807b838dd1"},{"id":"func/MemoPollCreate.isTooLong","name":"MemoPollCreate.isTooLong","line":43,"end_line":45,"hash":"ca9087a454f1fba64ac35738a037372ade36ba6d4c2270d2025adc00b860412c"},{"id":"func/MemoPollCreate.create","name":"MemoPollCreate.create","line":48,"end_line":71,"hash":"dd0f77edcf6d20b5273c4949a0b7b52f7b60686556b33491139077bbb6866120"},{"id":"func/MemoPollCreate.reflect","name":"MemoPollCreate.reflect","line":74,"end_line":84,"hash":"ec559b778b279c1efb46bdb2440a0c197dae242052d7c1ebf5331078258b719f"},{"id":"func/buildCreatePollPushes","name":"buildCreatePollPushes","line":89,"end_line":96,"hash":"54b38bb7fba8a90968c93441588aa42754d2b961d75778daf8b764a017030334"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-poll-option.js b/psf-memo-client/src/services/memo-poll-option.js index b8455ce..38c4eaa 100644 --- a/psf-memo-client/src/services/memo-poll-option.js +++ b/psf-memo-client/src/services/memo-poll-option.js @@ -45,5 +45,5 @@ MemoPollOption.MAX_OPTION_BYTES = MAX_OPTION_BYTES module.exports = MemoPollOption // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-28T23:38:02.285Z","module_hash":"f8432d9ccb0073f5cce9f96415fdfb033876f498a7d80aadaca1ed96d89d4ce3","functions":[{"id":"func/MemoPollOption.add","name":"MemoPollOption.add","line":37,"end_line":39,"hash":"e0343ddce138d48e3fec2c5c0169e9d99f374835cc95d381383a254297d7f1dc"}]} +// {"version":1,"tested_at":"2026-09-17T04:16:42.577Z","module_hash":"4bcfeb64bcefaafb3d61272db2623e832b65930f966f5494bebfd417b29dc833","functions":[{"id":"func/MemoPollOption.add","name":"MemoPollOption.add","line":37,"end_line":39,"hash":"d8bdf2dda07743c5d6927b7a6ef69715f033aefe2e77b5ca6923c2afe3772088"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-poll-vote.js b/psf-memo-client/src/services/memo-poll-vote.js index 08d3d8e..81cf699 100644 --- a/psf-memo-client/src/services/memo-poll-vote.js +++ b/psf-memo-client/src/services/memo-poll-vote.js @@ -45,5 +45,5 @@ MemoPollVote.MAX_COMMENT_BYTES = MAX_COMMENT_BYTES module.exports = MemoPollVote // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-28T23:38:23.086Z","module_hash":"c60989293dede6eeaf8edd168d00745e7299620b80874f87680337cc2f4d36fc","functions":[{"id":"func/MemoPollVote.vote","name":"MemoPollVote.vote","line":37,"end_line":39,"hash":"c6c194dfaf6c92f5cafcf86772bd295d038536ea20079931e4144eb5141c7101"}]} +// {"version":1,"tested_at":"2026-09-17T04:17:10.193Z","module_hash":"e03a12dfad981e8a9ce4d4af763f0c57bd7a0f70a8bf22adc4b83d539b4e4725","functions":[{"id":"func/MemoPollVote.vote","name":"MemoPollVote.vote","line":37,"end_line":39,"hash":"e7ec8ae8f66c762cd7bb9fd9bb3001fe2a1b63843933d57372d6fd6f6a87b3dc"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-reply.js b/psf-memo-client/src/services/memo-reply.js index a7fa557..4855132 100644 --- a/psf-memo-client/src/services/memo-reply.js +++ b/psf-memo-client/src/services/memo-reply.js @@ -85,5 +85,5 @@ MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES module.exports = MemoReply // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-16T22:14:19.213Z","module_hash":"6c515a91afe94a6be8671a1541302446bb6b1015d03ce0e278644508ebd20090","functions":[{"id":"func/MemoReply.constructor","name":"MemoReply.constructor","line":36,"end_line":39,"hash":"23091c1b8f7847199bab3b54d8d81e9d8432c6da96138d72ac03fcf9426d542c"},{"id":"func/MemoReply.isTooLong","name":"MemoReply.isTooLong","line":42,"end_line":44,"hash":"2e867501d184010313ba9b27a6bb1e446df8f093514ee231b90ce77699ecbaf2"},{"id":"func/MemoReply.reply","name":"MemoReply.reply","line":48,"end_line":67,"hash":"00fb3598d5c3bdc3c6744d1316ad552364574fed105438af4aa011347f3306ce"},{"id":"func/MemoReply.reflect","name":"MemoReply.reflect","line":70,"end_line":79,"hash":"344e1bf304a4dfd475b02824b7ddbf555da0f3ec89f73b4f6009cf0bf097fb02"}]} +// {"version":1,"tested_at":"2026-09-17T04:12:17.098Z","module_hash":"53a16ce9198c0c94446ca0f89463072d83a9646de676708a1b9061086dbac40c","functions":[{"id":"func/MemoReply.constructor","name":"MemoReply.constructor","line":36,"end_line":39,"hash":"23091c1b8f7847199bab3b54d8d81e9d8432c6da96138d72ac03fcf9426d542c"},{"id":"func/MemoReply.isTooLong","name":"MemoReply.isTooLong","line":42,"end_line":44,"hash":"2e867501d184010313ba9b27a6bb1e446df8f093514ee231b90ce77699ecbaf2"},{"id":"func/MemoReply.reply","name":"MemoReply.reply","line":48,"end_line":67,"hash":"b8dc4228aefdae7a9e29273a7cef7d92177af9ba6d19d318d6455bf4d202fcc6"},{"id":"func/MemoReply.reflect","name":"MemoReply.reflect","line":70,"end_line":79,"hash":"344e1bf304a4dfd475b02824b7ddbf555da0f3ec89f73b4f6009cf0bf097fb02"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-topic-post.js b/psf-memo-client/src/services/memo-topic-post.js index ab9802f..77b9a14 100644 --- a/psf-memo-client/src/services/memo-topic-post.js +++ b/psf-memo-client/src/services/memo-topic-post.js @@ -88,5 +88,5 @@ MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES = MAX_TOPIC_MESSAGE_BYTES module.exports = MemoTopicPost // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-28T18:18:11.190Z","module_hash":"c73b1395983accf085921554a84c8ef9ad8014ba8d8ddfa794e051ae4a487f04","functions":[{"id":"func/MemoTopicPost.constructor","name":"MemoTopicPost.constructor","line":34,"end_line":38,"hash":"34483e7579cbde3babf17cc43f64bc6535537b6391169a75cb51025029283289"},{"id":"func/MemoTopicPost.isTooLong","name":"MemoTopicPost.isTooLong","line":42,"end_line":44,"hash":"a8d16816cf0aeda1be759f796aee75e9cc5d89ccbcc288b5d2551917121be27a"},{"id":"func/MemoTopicPost.remainingBytes","name":"MemoTopicPost.remainingBytes","line":46,"end_line":48,"hash":"11a07f6539fa6b74feabfdd258f83ede4556213951aa85266a959871ff616df1"},{"id":"func/MemoTopicPost.post","name":"MemoTopicPost.post","line":51,"end_line":67,"hash":"dec3508e4969efd3edfefbc0752ca4608bac9521fbda62cf2ce5d7bf0a5696c7"},{"id":"func/MemoTopicPost.reflect","name":"MemoTopicPost.reflect","line":70,"end_line":79,"hash":"8a136fe98fa6752b2247b9bc0064b9860cfa34e518abaa33e50e95833120d529"}]} +// {"version":1,"tested_at":"2026-09-17T04:13:34.090Z","module_hash":"c718cc342a8316ce9369d79969492e7de83e2c8b9d881c2f3d0324ac92cdf0ff","functions":[{"id":"func/MemoTopicPost.constructor","name":"MemoTopicPost.constructor","line":34,"end_line":38,"hash":"34483e7579cbde3babf17cc43f64bc6535537b6391169a75cb51025029283289"},{"id":"func/MemoTopicPost.isTooLong","name":"MemoTopicPost.isTooLong","line":42,"end_line":44,"hash":"a8d16816cf0aeda1be759f796aee75e9cc5d89ccbcc288b5d2551917121be27a"},{"id":"func/MemoTopicPost.remainingBytes","name":"MemoTopicPost.remainingBytes","line":46,"end_line":48,"hash":"11a07f6539fa6b74feabfdd258f83ede4556213951aa85266a959871ff616df1"},{"id":"func/MemoTopicPost.post","name":"MemoTopicPost.post","line":51,"end_line":70,"hash":"626b580f6c5192b03254561430a28b04decf8d089fdb12542bea8a168803e945"},{"id":"func/MemoTopicPost.reflect","name":"MemoTopicPost.reflect","line":73,"end_line":82,"hash":"8a136fe98fa6752b2247b9bc0064b9860cfa34e518abaa33e50e95833120d529"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-txid-action.js b/psf-memo-client/src/services/memo-txid-action.js index b72d65c..ad80209 100644 --- a/psf-memo-client/src/services/memo-txid-action.js +++ b/psf-memo-client/src/services/memo-txid-action.js @@ -73,5 +73,5 @@ class MemoTxidAction extends MemoAction { module.exports = MemoTxidAction // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-28T23:37:21.781Z","module_hash":"b43e695aee6198c465b2cdbb0fbb405fccb0aadb573f56c3d3249de3920c1939","functions":[{"id":"func/MemoTxidAction.constructor","name":"MemoTxidAction.constructor","line":18,"end_line":22,"hash":"399be38855508b45b6a07e00361d4fb36186df3fdac5e2a68e004f7dcbdd353c"},{"id":"func/MemoTxidAction.broadcastTxid","name":"MemoTxidAction.broadcastTxid","line":26,"end_line":48,"hash":"395ea932dd691049556a66a3d4c674778818b8b53152c0ae7088d960524fc400"},{"id":"func/MemoTxidAction.reflect","name":"MemoTxidAction.reflect","line":53,"end_line":63,"hash":"7d61638162b7142298e021b02cbdeaca359f33503b3b84a308498f0479c80c5c"},{"id":"func/MemoTxidAction.isTooLong","name":"MemoTxidAction.isTooLong","line":67,"end_line":69,"hash":"7eeb0b9dcb35530de412b0aa0ade6b267e2fe22f5e49aa0f3ecf5b9124022a3b"}]} +// {"version":1,"tested_at":"2026-09-17T04:18:05.479Z","module_hash":"d0bda2cafd87fb912744a896f53c74aac9402bc087e106eccbf19144b9f93e35","functions":[{"id":"func/MemoTxidAction.constructor","name":"MemoTxidAction.constructor","line":18,"end_line":22,"hash":"399be38855508b45b6a07e00361d4fb36186df3fdac5e2a68e004f7dcbdd353c"},{"id":"func/MemoTxidAction.broadcastTxid","name":"MemoTxidAction.broadcastTxid","line":27,"end_line":49,"hash":"60e57cd10e406285825ad6750d9cce312ea5ecb09e02a697da358ac65f8312a5"},{"id":"func/MemoTxidAction.reflect","name":"MemoTxidAction.reflect","line":54,"end_line":64,"hash":"7d61638162b7142298e021b02cbdeaca359f33503b3b84a308498f0479c80c5c"},{"id":"func/MemoTxidAction.isTooLong","name":"MemoTxidAction.isTooLong","line":68,"end_line":70,"hash":"7eeb0b9dcb35530de412b0aa0ade6b267e2fe22f5e49aa0f3ecf5b9124022a3b"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/utf8.js b/psf-memo-client/src/services/utf8.js index dcbb0b0..5b7f2b0 100644 --- a/psf-memo-client/src/services/utf8.js +++ b/psf-memo-client/src/services/utf8.js @@ -21,5 +21,5 @@ function byteLength (str) { module.exports = { encodeUtf8, byteLength } // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-26T12:17:55.885Z","module_hash":"7f91541c49f2b6f421e8d4158bfe808b35a5449534cb26c567162fce6fec64bf","functions":[{"id":"func/byteLength","name":"byteLength","line":11,"end_line":13,"hash":"973c9dadcd1d8bbd53587252443880db13c8be3639fa74ce3c69a08ea358c8e2"}]} +// {"version":1,"tested_at":"2026-09-17T04:11:15.796Z","module_hash":"530eeae383fa202f8e50d83126a9bafaaa49b04268f297e9e91027a89cde71b4","functions":[{"id":"func/encodeUtf8","name":"encodeUtf8","line":12,"end_line":14,"hash":"8d56bdd5607b741578d5f51511391e0376cc410181401b2570fde0990b6bf976"},{"id":"func/byteLength","name":"byteLength","line":17,"end_line":19,"hash":"4d5266d6b3715a761b533e67477846156cfbd497460e5af448a8ea7a72bb580c"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/test/property/memo-multipush.property.test.js b/psf-memo-client/test/property/memo-multipush.property.test.js index e5cde2a..f29e2bb 100644 --- a/psf-memo-client/test/property/memo-multipush.property.test.js +++ b/psf-memo-client/test/property/memo-multipush.property.test.js @@ -24,6 +24,7 @@ const { buildPushes, attachMultiPushOpReturn } = require('../../src/services/memo-multipush') +const { encodeScript } = require('../support/script-encoding') const rng = seededRandom(20260916) @@ -61,17 +62,6 @@ function randomFields () { return fields } -// Encode a script the way Bitcoin does: an opcode number is one byte, a -// Buffer becomes a length-prefixed push. -function encodeScript (script) { - const parts = script.map((el) => { - if (typeof el === 'number') return Buffer.from([el]) - const buf = Buffer.from(el) - return Buffer.concat([Buffer.from([buf.length]), buf]) - }) - return Buffer.concat(parts) -} - // A double of the minimal-slp-wallet surface the adapter reuses. function makeWalletDouble () { const sent = [] diff --git a/psf-memo-client/test/support/script-encoding.js b/psf-memo-client/test/support/script-encoding.js new file mode 100644 index 0000000..4e20c3d --- /dev/null +++ b/psf-memo-client/test/support/script-encoding.js @@ -0,0 +1,20 @@ +/* + Test helper: encode a script the way Bitcoin does. + + An opcode number is one byte; a Buffer/string becomes a length-prefixed push. + Enough to observe that the multi-push adapter produced the expected pushes. + Shared by the memo-multipush unit and property tests. +*/ + +'use strict' + +function encodeScript (script) { + const parts = script.map((el) => { + if (typeof el === 'number') return Buffer.from([el]) + const buf = Buffer.from(el) + return Buffer.concat([Buffer.from([buf.length]), buf]) + }) + return Buffer.concat(parts) +} + +module.exports = { encodeScript } diff --git a/psf-memo-client/test/unit/memo-multipush.test.js b/psf-memo-client/test/unit/memo-multipush.test.js index 7d76835..9faced2 100644 --- a/psf-memo-client/test/unit/memo-multipush.test.js +++ b/psf-memo-client/test/unit/memo-multipush.test.js @@ -18,17 +18,7 @@ const { broadcastMultiPush, attachMultiPushOpReturn } = require('../../src/services/memo-multipush') - -// Encode a script the way Bitcoin does: an opcode number is one byte, a -// Buffer/string becomes a length-prefixed push. Enough to observe the pushes. -function encodeScript (script) { - const parts = script.map((el) => { - if (typeof el === 'number') return Buffer.from([el]) - const buf = Buffer.from(el) - return Buffer.concat([Buffer.from([buf.length]), buf]) - }) - return Buffer.concat(parts) -} +const { encodeScript } = require('../support/script-encoding') // A double of the minimal-slp-wallet surface the adapter reuses. function makeSlpWalletDouble () { @@ -75,6 +65,24 @@ test('toPushBuffer encodes a string as UTF-8 and passes bytes through', () => { assert.equal(toPushBuffer(Uint8Array.from([1, 2])).toString('hex'), '0102') }) +// The browser bundle has no global Buffer (CRA 5 does not polyfill Node +// globals and the wallet script does not set window.Buffer), so the adapter +// must import its own. Re-require it with the global removed to prove it. +test('the adapter builds pushes without a browser-global Buffer', () => { + const modulePath = require.resolve('../../src/services/memo-multipush') + const savedBuffer = global.Buffer + global.Buffer = undefined + delete require.cache[modulePath] + try { + const fresh = require('../../src/services/memo-multipush') + assert.equal(fresh.toPushBuffer('hi').toString('hex'), '6869') + assert.equal(fresh.buildPushes('6d03', ['hi'])[0].toString('hex'), '6d03') + } finally { + global.Buffer = savedBuffer + delete require.cache[modulePath] + } +}) + test('buildPushes puts the prefix first and each field in order', () => { const pushes = buildPushes('6d03', [Buffer.from('aa', 'hex'), 'hi']) @@ -116,13 +124,22 @@ test('a single field delegates to the original wallet sendOpReturn', async () => test('attaching twice does not double-wrap the wallet', async () => { const { wallet } = makeSlpWalletDouble() attachMultiPushOpReturn(wallet) + const wrapped = wallet.sendOpReturn attachMultiPushOpReturn(wallet) + // The second attach must leave the wrapper untouched rather than nesting it. + assert.equal(wallet.sendOpReturn, wrapped) + await wallet.sendOpReturn('single', '6d02') assert.equal(wallet.calls.length, 1) }) +test('attaching to a falsy wallet is a no-op', () => { + assert.equal(attachMultiPushOpReturn(null), null) + assert.equal(attachMultiPushOpReturn(undefined), undefined) +}) + test('broadcastMultiPush rejects a wallet without the OP_RETURN builder', async () => { await assert.rejects( broadcastMultiPush({ opReturn: {} }, ['hi'], '6d02'), From 18d1fb122285b21c59c08144fd14680c79fa2af0 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 21:34:14 -0700 Subject: [PATCH 4/5] Record multi-push encoding review and verification By architect. --- .../memo-multipush-encoding-summary.md | 164 ++++++++++++++++++ .../memo-multipush-encoding-verification.json | 46 +++++ 2 files changed, 210 insertions(+) create mode 100644 docs/reviews/memo-multipush-encoding-summary.md create mode 100644 docs/reviews/memo-multipush-encoding-verification.json diff --git a/docs/reviews/memo-multipush-encoding-summary.md b/docs/reviews/memo-multipush-encoding-summary.md new file mode 100644 index 0000000..f8b1811 --- /dev/null +++ b/docs/reviews/memo-multipush-encoding-summary.md @@ -0,0 +1,164 @@ +# memo-multipush-encoding — Architect Review + +Task: `memo-multipush-encoding` +Component: `psf-memo-client` +Base: `c018a01` (last merged architect review); inbound refactorer commit `8310b6a` + +## What was reviewed + +Inbound refactorer batch (priority 10), merged onto `swarmforge-architect` by +fast-forwarding to `8310b6a`. The linear chain reviewed: + +- **`6eddd9c`** — specifier: *Specify multi-push encoding for Memo multi-field + actions*. Adds `psf-memo-client/specs/memo-multipush-encoding.feature` (5 + scenario outlines) requiring reply, topic message, add-poll-option, and + poll-vote to broadcast the prefix plus fields as **3 separate OP_RETURN + pushes**, and create-poll as **4 pushes**. +- **`2e2db86`** — coder: *Implement multi-push encoding for Memo multi-field + actions*. Rewrites `hex.js` to return a pushes array instead of one combined + payload, adds the `memo-multipush` wallet adapter, switches the five action + services to pass field arrays, adds acceptance step handlers, and adds + unit/property tests. +- **`8310b6a`** — refactorer: *Refactor multi-push helpers and add adapter + property tests*. Shares `encodeUtf8` across `hex`/poll-create/topic-post, + drops the redundant `Uint8Array` branch in the push normalizer, renames the + txid-action builder parameter `buildPayload` → `buildPushes`, extracts the DB + `repairedFixture`, and adds property tests for the adapter. + +**Architect review commit: `fac01730c4`** — the browser-Buffer fix and the +regression test, the `encodeScript` test-helper extraction, the acceptance +assertion de-duplication, the `buffer` dependency, and the `mutate4javascript` +and soft `gherkin-mutator` manifests. The summary and verification record are +committed on top, so `git diff fac01730c4 HEAD` touches only `docs/`. The +record's `git_sha` is `fac01730c4`, the commit that contains the verified source +state. + +## Architectural findings and fixes applied + +The refactorer's structure is sound: `hex.js` and `utf8.js` are pure leaves, +the five action services are core modules with injected wallet/store adapters, +and `memo-multipush.js` confines the wallet wiring to a small adapter boundary. +Two real issues were found and fixed. + +1. **Browser runtime defect — the adapter read the Node global `Buffer`.** This + is the important finding. `memo-multipush.js` called `Buffer.from(...)` + directly, but the CRA 5 production bundle does **not** polyfill Node globals + and the externally loaded wallet script does not define `window.Buffer` + (verified: the built bundle had no `Buffer` assignment and the only other + `Buffer.from` came from axios behind a `typeof Blob` guard). Every real + browser reply, topic message, poll option, poll vote, and create-poll would + have thrown `Buffer is not defined` at runtime, while the Node test suite + passed. `@psf/bitcoincashjs-lib`'s `compile2` requires genuine `Buffer` + instances (`Buffer.isBuffer` + `.copy`), so a `Uint8Array` substitution is + not possible. Fix: import the browser-safe implementation + (`const { Buffer } = require('buffer')`) — the same module the bundled + bitcoin library uses — and declare `buffer` as a direct dependency so the + adapter does not depend on a dev-transitive package. Confirmed in the built + bundle: the adapter now compiles to `const{Buffer:r}=n(6382)` instead of a + free global. A regression test re-requires the module with `global.Buffer` + removed and asserts pushes still build. +2. **Mutation survivors in `attachMultiPushOpReturn`.** The first run killed 2 + of 4 sites. The `!wallet || wallet.__multiPushAttached` logical mutation + survived because nothing tested a falsy wallet, and `__multiPushAttached = + true` survived because the idempotence test only counted wallet calls (which + a harmless double-wrap does not change). Focused tests now assert a falsy + wallet is a no-op and that a second attach leaves the wrapper function + identity unchanged; the file re-runs 4/4. +3. **DRY — duplicated test/acceptance helpers.** The `encodeScript` Bitcoin + script double was copied verbatim between the new unit and property tests; + extracted to `test/support/script-encoding.js` (a test helper, outside + `test/unit/**` and `test/property/*.test.js`). The three new acceptance + handlers that assert a UTF-8 push (topic/text/question) were structurally + identical; extracted `assertUtf8Push`. Scoped DRY dropped the duplicate + blocks in the touched set from 132 to 129 and removed the new-handler + duplicates. +4. **UI/Core separation, dependency rule, information hiding.** `memo-multipush` + depends on nothing; `hex`/`utf8` are pure; the action services depend inward + on those leaves; no React/DOM/router/LevelDB/wallet-global leaks. The wire + order still has one definition (`txidToWireBytes`) and the txid+text push + shape one definition (`buildTxidTextPushes`). The adapter hides the + `bchjs.Script.encode2` swap behind `attachMultiPushOpReturn`, restoring it + synchronously inside the same tick (the property tests pin the expansion and + delegation contract). + +No further boundary change was required. + +## Verification results + +### Language mutation (`mutate4javascript`, `--mutate-all` when the wrapper +detected differential under-selection, `--max-workers 8`) + +| File | Sites | Killed | Survived | Uncovered | +|------|------:|-------:|---------:|----------:| +| `src/services/memo-multipush.js` | 4 | 4 | 0 | 0 | +| `src/services/hex.js` | 5 | 5 | 0 | 0 | +| `src/services/utf8.js` | 0 | 0 | 0 | 0 | +| `src/services/memo-reply.js` | 2 | 2 | 0 | 0 | +| `src/services/memo-topic-post.js` | 7 | 7 | 0 | 0 | +| `src/services/memo-poll-create.js` | 7 | 7 | 0 | 0 | +| `src/services/memo-poll-option.js` | 0 | 0 | 0 | 0 | +| `src/services/memo-poll-vote.js` | 0 | 0 | 0 | 0 | +| `src/services/memo-txid-action.js` | 4 | 4 | 0 | 0 | + +`utf8.js`, `memo-poll-option.js`, and `memo-poll-vote.js` were confirmed with +`--scan` as structural zeros (`Total mutation sites: 0`): they contain no +arithmetic/comparison/boolean/logical/`0<->1` sites, only string/array work and +base-class delegation. `async-load.js` is the browser/wallet adapter shell and +is excluded from tools that run the test suite, consistent with the standing +precedent for adapter shells. + +### DRY (`dry4javascript`, scoped to the touched production files, tests, and +adapters) + +129 duplicate blocks remain in the scoped set, all pre-existing pattern +boilerplate: `acceptance/lib/handlers.js` step-handler repetition (192 of the +line references), the established per-suite `makeWallet` test double (also +present across ~28 client test files), and parallel poll/topic/reply unit-test +bodies that exercise the parallel `MemoTxidAction` subclasses on purpose. The +only two task-local duplications (`encodeScript`, the three UTF-8 push +assertions) were extracted. The production-service duplicates +(`memo-poll-option`/`memo-poll-vote` config blocks, the two 2-field +constructors) are intentional parallel-config declarations and were left as-is +to preserve their per-action clarity. + +### CRAP / cyclomatic complexity (`crap4javascript`) + +All changed functions at or below CRAP 5.0 with ~100% coverage: +`hexToBytes` (CC 5, 100%, 5.0), `MemoPollCreate.create` (CC 4, 4.0), +`attachMultiPushOpReturn`/`broadcastMultiPush` (CC 3, 3.0), and the rest CC ≤ 3. +`MemoReply.reply` is 90% covered but CRAP 2.0. Well below the 8.0 threshold. + +### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`) + +**`memo-multipush-encoding.feature`: 20 executed, 8 killed, 12 survived, 0 +errors.** Every survivor is a single-character case mutation of a `text`, +`topic`, or `question` example value. Those values are used consistently on the +setup and assertion sides of their scenario (`...with the text ""` / +`...is the UTF-8 text ""`), so the mutant is an intrinsic equivalent: the +push bytes match whatever the example says. The point of the feature — the +separate-push shape (count), the little-endian wire txid, the poll type, and the +option count — is killed (all 8 non-text mutations, including the `option_count` +and txid mutations). No implementation change is warranted. The tool left its +manifest (empty `scenarios`, because every scenario has equivalents) in the +feature file; it is committed as tool-written. + +### Suite status + +Canonical record against review commit +`fac01730c4beaf5b412302f919e47871778f4d08`: + +- `swarmforge/scripts/verify.sh client --record + docs/reviews/memo-multipush-encoding-verification.json --task + memo-multipush-encoding` -> **pass (5/5)**: unit **425 pass / 0 fail**, + property **85 pass / 0 fail**, acceptance **all 31 suites passed**, lint + **ok**, build **ok**. + +## Handoffs sent + +- End-of-chain `git_handoff` to the specifier (task `memo-multipush-encoding`) + with the review commit `fac01730c4` so it can merge `swarmforge-architect` + into `master`. +- No coder/refactorer handoff: the review is a runtime fix, mutation hardening, + and local DRY with no follow-up work for those roles. + +By architect. diff --git a/docs/reviews/memo-multipush-encoding-verification.json b/docs/reviews/memo-multipush-encoding-verification.json new file mode 100644 index 0000000..a929f88 --- /dev/null +++ b/docs/reviews/memo-multipush-encoding-verification.json @@ -0,0 +1,46 @@ +{ + "schema_version": 1, + "task": "memo-multipush-encoding", + "component": "psf-memo-client", + "git_sha": "fac01730c4beaf5b412302f919e47871778f4d08", + "branch": "swarmforge-architect", + "timestamp": "2026-09-17T04:33:07.792Z", + "commands": [ + { + "name": "unit", + "command": "npm test", + "exit": 0, + "duration_ms": 8897, + "summary": "425 pass / 0 fail" + }, + { + "name": "property", + "command": "npm run test:property", + "exit": 0, + "duration_ms": 10258, + "summary": "85 pass / 0 fail" + }, + { + "name": "acceptance", + "command": "npm run test:acceptance", + "exit": 0, + "duration_ms": 14315, + "summary": "all 31 acceptance suites passed" + }, + { + "name": "lint", + "command": "npm run lint", + "exit": 0, + "duration_ms": 2278, + "summary": "ok" + }, + { + "name": "build", + "command": "npm run build", + "exit": 0, + "duration_ms": 60886, + "summary": "ok" + } + ], + "result": "pass" +} From 6050d80f6311f7498d4425f4b0d0bc8772ad2902 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 21:35:09 -0700 Subject: [PATCH 5/5] Note gherkin-mutator empty-manifest behavior for intrinsic survivors By architect. --- docs/architect-process-notes.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/architect-process-notes.md b/docs/architect-process-notes.md index 2747ebc..2ea4205 100644 --- a/docs/architect-process-notes.md +++ b/docs/architect-process-notes.md @@ -113,6 +113,15 @@ the captured output before the tool's `System/exit`; the JSON report (`--json`) did. Redirect stdout to a file and use `--json` for a reliable, parseable record of killed/survived mutations. +- **`gherkin-mutator` writes an empty `scenarios` manifest when every scenario + has an intrinsic survivor.** `new-manifest` records only scenarios with + `Survived = 0` and `Errors = 0`. When all mutations are single-character case + changes of example values used consistently on both the setup and assertion + sides, every scenario survives and the committed manifest is + `"scenarios":[]` with no `# mutation-stamp`. This is the tool's expected + output (those scenarios are intentionally re-mutated next run), not a partial + write; commit it as-is and document the equivalents in the review summary. + - **`gherkin-mutator` can run from `tmp/aps` with absolute component paths.** The Babashka task must run where `bb.edn` defines it (`tmp/aps`), but the runner worker resolves the job's `feature_json`/`work_dir`/`generated_dir`