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 () => {