mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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.
This commit is contained in:
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}]}
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user