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.
This commit is contained in:
Chris Troutner
2026-09-16 20:37:59 -07:00
parent 6eddd9c3f8
commit 2e2db86263
18 changed files with 431 additions and 102 deletions
+125 -6
View File
@@ -58,6 +58,7 @@ const { renderPostOptions } = require('./render-post-options')
const { renderLikeResult } = require('./render-like-result') const { renderLikeResult } = require('./render-like-result')
const PostOptions = require('../../src/services/post-options') const PostOptions = require('../../src/services/post-options')
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed') 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_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
@@ -102,12 +103,19 @@ function makeWallet (address) {
return this.utxos return this.utxos
}, },
sendOpReturn: async function (msg, prefix, bchOutput = []) { sendOpReturn: async function (msg, prefix, bchOutput = []) {
// Normalize binary payloads to Buffer so assertions can safely use // Multi-field actions pass an array of field pushes. Expose each push
// toString('hex'), while preserving string payloads unchanged. // separately for the multi-push assertions, while keeping `msg` as the
const storedMsg = (msg instanceof Uint8Array || ArrayBuffer.isView(msg)) // 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) ? Buffer.from(msg)
: msg : msg)
this.broadcasts.push({ msg: storedMsg, prefix, bchOutput }) const pushes = [Buffer.from(prefix, 'hex'), ...fieldBuffers]
this.broadcasts.push({ msg: storedMsg, pushes, prefix, bchOutput })
if (this.failWith) throw new Error(this.failWith) if (this.failWith) throw new Error(this.failWith)
return 'aa'.repeat(32) return 'aa'.repeat(32)
} }
@@ -559,6 +567,28 @@ function decodeLikeTxid (raw) {
return Buffer.from(raw).reverse().toString('hex') 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 <parameter> placeholder from the example store. // Resolve a literal value or a <parameter> placeholder from the example store.
function resolveParam (value, example) { function resolveParam (value, example) {
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim()) 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', 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_]+)>"$/, 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}".`) throw new Error(`Expected Memo topic-message prefix ${MEMO_TOPIC_MESSAGE_PREFIX}, got "${last.prefix}".`)
} }
const expectedPayload = room + world.topicPostPage.input 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.`) throw new Error(`Broadcast topic-message payload did not match ${room} + input.`)
} }
} }
@@ -6,6 +6,7 @@
import axios from 'axios' import axios from 'axios'
// Local libraries // Local libraries
import GistServers from './gist-servers' import GistServers from './gist-servers'
import memoMultipush from './memo-multipush'
class AsyncLoad { class AsyncLoad {
constructor () { constructor () {
@@ -66,6 +67,10 @@ class AsyncLoad {
this.wallet = wallet this.wallet = wallet
// Teach the wallet to broadcast multi-field Memo actions as separate
// OP_RETURN pushes.
memoMultipush.attachMultiPushOpReturn(wallet)
return wallet return wallet
} catch (error) { } catch (error) {
console.error('Error initializing wallet: ', error) console.error('Error initializing wallet: ', error)
@@ -240,6 +245,10 @@ class AsyncLoad {
this.wallet = wallet this.wallet = wallet
// Teach the wallet to broadcast multi-field Memo actions as separate
// OP_RETURN pushes.
memoMultipush.attachMultiPushOpReturn(wallet)
return wallet return wallet
} catch (error) { } catch (error) {
console.error('Error initStarterWallet: ', error) console.error('Error initStarterWallet: ', error)
+6 -9
View File
@@ -33,19 +33,16 @@ function txidToWireBytes (txid, label = 'Value') {
return hexToBytes(txid, 32, label).reverse() return hexToBytes(txid, 32, label).reverse()
} }
// Build the raw OP_RETURN payload for a txid-referencing Memo action: the // Build the separate OP_RETURN pushes for a txid-referencing Memo action:
// given 32-byte txid in little-endian wire order followed by a UTF-8 encoded // the referenced txid in little-endian wire order and the UTF-8 encoded value,
// value. The label customizes the invalid-txid error message. // each as its own push. The label customizes the invalid-txid error message.
function buildTxidTextPayload (txid, text, label = 'Poll txid') { function buildTxidTextPushes (txid, text, label = 'Poll txid') {
const txidBytes = txidToWireBytes(txid, label) const txidBytes = txidToWireBytes(txid, label)
const textBytes = new TextEncoder().encode(text) const textBytes = new TextEncoder().encode(text)
const raw = new Uint8Array(txidBytes.length + textBytes.length) return [txidBytes, textBytes]
raw.set(txidBytes, 0)
raw.set(textBytes, txidBytes.length)
return raw
} }
module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPayload } module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPushes }
// mutate4javascript-manifest-begin // 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-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"}]}
@@ -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 <prefix> <field> <field> ... . 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
}
@@ -62,8 +62,8 @@ class MemoPollCreate extends MemoAction {
await this.wallet.getUtxos() await this.wallet.getUtxos()
const raw = buildCreatePollPayload(question, this.pollType, count) const pushes = buildCreatePollPushes(question, this.pollType, count)
const txid = await this.wallet.sendOpReturn(raw, this.prefix) const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
this.reflect(txid, question, count) 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. // Build the separate OP_RETURN pushes for a create-poll action: the poll type
// The protocol wire format is: <poll_type 1 byte><option_count 1 byte><question UTF-8 bytes>. // byte, the option count byte, and the UTF-8 question, each as its own push.
function buildCreatePollPayload (question, pollType, optionCount) { function buildCreatePollPushes (question, pollType, optionCount) {
const textBytes = new TextEncoder().encode(question) const textBytes = new TextEncoder().encode(question)
const raw = new Uint8Array(2 + textBytes.length) return [
raw[0] = pollType & 0xff Uint8Array.from([pollType & 0xff]),
raw[1] = optionCount & 0xff Uint8Array.from([optionCount & 0xff]),
raw.set(textBytes, 2) textBytes
return raw ]
} }
MemoPollCreate.MEMO_CREATE_POLL_PREFIX = MEMO_CREATE_POLL_PREFIX MemoPollCreate.MEMO_CREATE_POLL_PREFIX = MEMO_CREATE_POLL_PREFIX
@@ -15,7 +15,7 @@
*/ */
const MemoTxidAction = require('./memo-txid-action') const MemoTxidAction = require('./memo-txid-action')
const { buildTxidTextPayload } = require('./hex') const { buildTxidTextPushes } = require('./hex')
const MEMO_ADD_POLL_OPTION_PREFIX = '6d13' const MEMO_ADD_POLL_OPTION_PREFIX = '6d13'
const MAX_OPTION_BYTES = 184 const MAX_OPTION_BYTES = 184
@@ -35,7 +35,7 @@ class MemoPollOption extends MemoTxidAction {
// Compose and broadcast a Memo add-poll-option action. // Compose and broadcast a Memo add-poll-option action.
add (option) { add (option) {
return this.broadcastTxid(option, buildTxidTextPayload) return this.broadcastTxid(option, buildTxidTextPushes)
} }
} }
@@ -15,7 +15,7 @@
*/ */
const MemoTxidAction = require('./memo-txid-action') const MemoTxidAction = require('./memo-txid-action')
const { buildTxidTextPayload } = require('./hex') const { buildTxidTextPushes } = require('./hex')
const MEMO_POLL_VOTE_PREFIX = '6d14' const MEMO_POLL_VOTE_PREFIX = '6d14'
const MAX_COMMENT_BYTES = 184 const MAX_COMMENT_BYTES = 184
@@ -35,7 +35,7 @@ class MemoPollVote extends MemoTxidAction {
// Compose and broadcast a Memo poll-vote action. // Compose and broadcast a Memo poll-vote action.
vote (comment) { vote (comment) {
return this.broadcastTxid(comment, buildTxidTextPayload) return this.broadcastTxid(comment, buildTxidTextPushes)
} }
} }
+4 -4
View File
@@ -18,7 +18,7 @@
const MemoAction = require('./memo-action') const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8') const { byteLength } = require('./utf8')
const { buildTxidTextPayload } = require('./hex') const { buildTxidTextPushes } = require('./hex')
const MEMO_REPLY_PREFIX = '6d03' const MEMO_REPLY_PREFIX = '6d03'
const MAX_REPLY_BYTES = 184 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. // Refresh the wallet's spendable UTXO store so the broadcast has inputs.
await this.wallet.getUtxos() await this.wallet.getUtxos()
// Build the raw payload: parent txid bytes followed by UTF-8 message bytes. // Build the separate pushes: parent txid bytes, then UTF-8 message bytes.
const raw = buildTxidTextPayload(parentTxid, message, 'Parent txid') const pushes = buildTxidTextPushes(parentTxid, message, 'Parent txid')
const txid = await this.wallet.sendOpReturn(raw, this.prefix) const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
// Reflect the result on the injected thread once broadcast succeeds. // Reflect the result on the injected thread once broadcast succeeds.
this.reflect(txid, message, parentTxid) this.reflect(txid, message, parentTxid)
@@ -58,8 +58,11 @@ class MemoTopicPost extends MemoAction {
await this.wallet.getUtxos() await this.wallet.getUtxos()
const payload = this.room + message const pushes = [
const txid = await this.wallet.sendOpReturn(payload, this.prefix) new TextEncoder().encode(this.room),
new TextEncoder().encode(message)
]
const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
this.reflect(txid, message) this.reflect(txid, message)
@@ -4,8 +4,8 @@
These pin down invariants over broad random inputs that the unit tests only These pin down invariants over broad random inputs that the unit tests only
probe at fixed fixtures: probe at fixed fixtures:
- Round-trip: buildTxidTextPayload encodes a poll txid + text into the - Round-trip: buildTxidTextPushes encodes a poll txid + text into the
canonical Memo wire payload, so the stored reverse-hex txid and the canonical Memo wire pushes, so the stored reverse-hex txid and the
UTF-8 text both decode back unchanged. UTF-8 text both decode back unchanged.
- hexToBytes length contract: only 64-character hex txids decode to - hexToBytes length contract: only 64-character hex txids decode to
32 bytes; everything else throws. 32 bytes; everything else throws.
@@ -19,7 +19,7 @@
const test = require('node:test') const test = require('node:test')
const { seededRandom, forAll } = require('./harness') 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 { byteLength } = require('../../src/services/utf8')
const MemoPollOption = require('../../src/services/memo-poll-option') const MemoPollOption = require('../../src/services/memo-poll-option')
const MemoPollVote = require('../../src/services/memo-poll-vote') 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( await forAll(
() => ({ txid: randomTxid(), text: randomText(200) }), () => ({ txid: randomTxid(), text: randomText(200) }),
({ txid, text }) => { ({ 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 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 UTF-8 bytes of the value. Reversing the wire bytes must recover
// the 64-character display txid. // the 64-character display txid.
const wire = Buffer.from(bytes.slice(0, 32)) const wire = Buffer.from(bytes.slice(0, 32))
@@ -18,7 +18,7 @@
const test = require('node:test') const test = require('node:test')
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const { seededRandom, forAll, intGen } = require('./harness') const { seededRandom, forAll, intGen } = require('./harness')
const { txidToWireBytes, buildTxidTextPayload } = require('../../src/services/hex') const { txidToWireBytes, buildTxidTextPushes } = require('../../src/services/hex')
const HEX_CHARS = '0123456789abcdef' const HEX_CHARS = '0123456789abcdef'
@@ -87,8 +87,8 @@ test('payload embeds the wire txid followed by the UTF-8 text', async () => {
await forAll( await forAll(
() => ({ txid: randomTxid(rng), text: randomText(rng) }), () => ({ txid: randomTxid(rng), text: randomText(rng) }),
({ txid, text }) => { ({ txid, text }) => {
const raw = buildTxidTextPayload(txid, text) const pushes = buildTxidTextPushes(txid, text)
const buf = Buffer.from(raw) const buf = Buffer.concat(pushes.map((p) => Buffer.from(p)))
const wire = Buffer.from(txidToWireBytes(txid)).toString('hex') const wire = Buffer.from(txidToWireBytes(txid)).toString('hex')
const expectedText = Buffer.from(text, 'utf8') 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 if (buf.subarray(0, 32).toString('hex') !== wire) return false
return buf.subarray(32).equals(expectedText) return buf.subarray(32).equals(expectedText)
}, },
{ label: 'buildTxidTextPayload shape' } { label: 'buildTxidTextPushes shape' }
) )
}) })
test('a reply payload rejects an invalid txid with the parent label', () => { test('a reply payload rejects an invalid txid with the parent label', () => {
assert.throws( 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/ /Parent txid must be a 64-character hex string/
) )
}) })
+12 -13
View File
@@ -4,14 +4,14 @@
Memo actions that embed a parent poll txid use hexToBytes to decode the 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 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 and hex-validity guards independently of the memo-poll broadcast path that
also reaches hexToBytes through buildTxidTextPayload. also reaches hexToBytes through buildTxidTextPushes.
*/ */
'use strict' 'use strict'
const test = require('node:test') const test = require('node:test')
const assert = require('node:assert/strict') 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. // A non-palindromic txid so a missing byte reversal is observable.
const DISPLAY_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' const DISPLAY_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
@@ -57,13 +57,13 @@ test('hexToBytes rejects a non-string value', () => {
) )
}) })
test('buildTxidTextPayload prefixes the raw txid bytes', () => { test('buildTxidTextPushes returns the txid and text as separate pushes', () => {
const raw = buildTxidTextPayload('ab'.repeat(32), 'hi') const pushes = buildTxidTextPushes('ab'.repeat(32), 'hi')
const buf = Buffer.from(raw)
assert.equal(buf.length, 32 + 2) assert.equal(pushes.length, 2)
assert.equal(buf[0], 0xab) assert.equal(Buffer.from(pushes[0]).length, 32)
assert.equal(buf.slice(32).toString('utf8'), 'hi') 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', () => { 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', () => { test('buildTxidTextPushes embeds the txid in little-endian wire order', () => {
const raw = buildTxidTextPayload(DISPLAY_TXID, 'hi') const pushes = buildTxidTextPushes(DISPLAY_TXID, 'hi')
const buf = Buffer.from(raw)
assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX) assert.equal(Buffer.from(pushes[0]).toString('hex'), WIRE_HEX)
assert.equal(buf.slice(32).toString('utf8'), 'hi') assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hi')
}) })
@@ -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)
})
@@ -28,16 +28,7 @@ function makeWallet (address = MY_ADDRESS) {
} }
} }
function decodePayload (raw) { test('create broadcasts the poll type, option count, and question as separate pushes', async () => {
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 () => {
const wallet = makeWallet() const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet }) 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.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollCreate.MEMO_CREATE_POLL_PREFIX) assert.equal(wallet.broadcasts[0].prefix, MemoPollCreate.MEMO_CREATE_POLL_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg) const pushes = wallet.broadcasts[0].msg
assert.equal(decoded.question, 'which is better?') assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(decoded.optionCount, 2) assert.equal(pushes.length, 3)
assert.equal(decoded.pollType, 1) 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 () => { test('create accepts an option count of one', async () => {
@@ -30,14 +30,7 @@ function makeWallet (address = MY_ADDRESS) {
} }
} }
function decodePayload (raw) { test('add broadcasts the poll txid and option as separate pushes', async () => {
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 () => {
const wallet = makeWallet() const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID }) 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.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX) assert.equal(wallet.broadcasts[0].prefix, MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg) const pushes = wallet.broadcasts[0].msg
assert.equal(decoded.pollTxid, POLL_TXID) assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(decoded.option, 'yes') 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 () => { test('add reflects the new option on the injected poll store', async () => {
@@ -30,14 +30,7 @@ function makeWallet (address = MY_ADDRESS) {
} }
} }
function decodePayload (raw) { test('vote broadcasts the poll txid and comment as separate pushes', async () => {
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 () => {
const wallet = makeWallet() const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID }) 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.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollVote.MEMO_POLL_VOTE_PREFIX) assert.equal(wallet.broadcasts[0].prefix, MemoPollVote.MEMO_POLL_VOTE_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg) const pushes = wallet.broadcasts[0].msg
assert.equal(decoded.pollTxid, POLL_TXID) assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(decoded.comment, 'yes') 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 () => { test('vote reflects the new vote on the injected poll store', async () => {
+6 -4
View File
@@ -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 wallet = makeWallet()
const memoReply = new MemoReply({ wallet }) 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.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoReply.MEMO_REPLY_PREFIX) assert.equal(wallet.broadcasts[0].prefix, MemoReply.MEMO_REPLY_PREFIX)
const buf = Buffer.from(wallet.broadcasts[0].msg) const pushes = wallet.broadcasts[0].msg
assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX) assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(buf.slice(32).toString('utf8'), 'hello memo') 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', () => { test('isTooLong accepts a message exactly at the byte limit', () => {
@@ -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 wallet = makeWallet()
const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' }) 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.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX) 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 () => { test('post reflects the new topic post on the injected feed', async () => {