From 8310b6a1d85271ed6738b78dbdc9e35ad0cbf3af Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 20:56:42 -0700 Subject: [PATCH] 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()