Broadcast follow/mute/unfollow/unmute hash160 payloads as Uint8Array

Replaces the Node-only Buffer global in memo-follow and memo-mute with
the existing hexToBytes helper, which returns a Uint8Array. Updates unit
and property tests to assert bytes without requiring Buffer in the
production source, and adds an acceptance handler for the new binary
payload wording so the Binary Payload Broadcast regression spec passes.

By coder.
This commit is contained in:
Chris Troutner
2026-09-04 11:13:15 -07:00
parent f741119d8e
commit 5e1f4739e3
7 changed files with 74 additions and 18 deletions
+38 -2
View File
@@ -98,8 +98,12 @@ function makeWallet (address) {
return this.utxos
},
sendOpReturn: async function (msg, prefix, bchOutput = []) {
// Record the broadcast attempt, then fail if configured to do so.
this.broadcasts.push({ 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 })
if (this.failWith) throw new Error(this.failWith)
return 'aa'.repeat(32)
}
@@ -1751,6 +1755,38 @@ const handlers = [
}
}
},
{
name: 'broadcasts OP_RETURN with Memo binary hash160 payload for address',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo (follow|unfollow|mute|unmute) prefix and the binary hash160 payload for the address (.+)$/,
run (m, example, world) {
const action = m[1]
const addr = resolveParam(m[2], example)
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
const prefix = {
follow: MEMO_FOLLOW_PREFIX,
unfollow: MEMO_UNFOLLOW_PREFIX,
mute: MEMO_MUTE_PREFIX,
unmute: MEMO_UNMUTE_PREFIX
}[action]
if (!prefix) {
throw new Error(`Unknown follow/mute action: ${action}`)
}
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== prefix) {
throw new Error(`Expected Memo ${action} prefix ${prefix}, got "${last.prefix}".`)
}
if (last.msg.length !== 20) {
throw new Error(`Broadcast ${action} payload is not 20 bytes.`)
}
if (last.msg.toString('hex') !== hash160) {
throw new Error(`Broadcast ${action} hash160 did not match ${addr}.`)
}
}
},
{
name: 'API serves topic with post count',
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\d+) posts?$/,
+2 -1
View File
@@ -17,6 +17,7 @@
*/
const MemoAction = require('./memo-action')
const { hexToBytes } = require('./hex')
const MEMO_FOLLOW_PREFIX = '6d06'
const MEMO_UNFOLLOW_PREFIX = '6d07'
@@ -78,7 +79,7 @@ class MemoFollow extends MemoAction {
await this.wallet.getUtxos()
const hash160 = this._toHash160(followeeAddr)
const raw = Buffer.from(hash160, 'hex')
const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160')
const txid = await this.wallet.sendOpReturn(raw, prefix)
+2 -1
View File
@@ -17,6 +17,7 @@
*/
const MemoAction = require('./memo-action')
const { hexToBytes } = require('./hex')
const MEMO_MUTE_PREFIX = '6d16'
const MEMO_UNMUTE_PREFIX = '6d17'
@@ -78,7 +79,7 @@ class MemoMute extends MemoAction {
await this.wallet.getUtxos()
const hash160 = this._toHash160(muteeAddr)
const raw = Buffer.from(hash160, 'hex')
const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160')
const txid = await this.wallet.sendOpReturn(raw, prefix)
@@ -24,6 +24,10 @@ const rng = seededRandom(20260830)
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const CHARS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
function broadcastHex (wallet, index = 0) {
return Buffer.from(wallet.broadcasts[index].msg).toString('hex')
}
// Deterministic 20-byte hash160 hex for any input string, mirroring what a
// real wallet's bch-js produces for a valid cash address.
function hash20 (s) {
@@ -86,9 +90,9 @@ test('follow broadcasts exactly one hash160 payload with the follow prefix', asy
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoFollow.MEMO_FOLLOW_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg instanceof Uint8Array &&
wallet.broadcasts[0].msg.length === MemoFollow.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
broadcastHex(wallet, 0) === hash20(addr)
},
{ label: 'follow broadcast conservation and hash160 length' }
)
@@ -104,9 +108,9 @@ test('unfollow broadcasts exactly one hash160 payload with the unfollow prefix',
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoFollow.MEMO_UNFOLLOW_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg instanceof Uint8Array &&
wallet.broadcasts[0].msg.length === MemoFollow.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
broadcastHex(wallet, 0) === hash20(addr)
},
{ label: 'unfollow broadcast conservation and hash160 length' }
)
@@ -24,6 +24,10 @@ const rng = seededRandom(20260830)
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const CHARS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
function broadcastHex (wallet, index = 0) {
return Buffer.from(wallet.broadcasts[index].msg).toString('hex')
}
// Deterministic 20-byte hash160 hex for any input string, mirroring what a
// real wallet's bch-js produces for a valid cash address.
function hash20 (s) {
@@ -86,9 +90,9 @@ test('mute broadcasts exactly one hash160 payload with the mute prefix', async (
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoMute.MEMO_MUTE_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg instanceof Uint8Array &&
wallet.broadcasts[0].msg.length === MemoMute.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
broadcastHex(wallet, 0) === hash20(addr)
},
{ label: 'mute broadcast conservation and hash160 length' }
)
@@ -104,9 +108,9 @@ test('unmute broadcasts exactly one hash160 payload with the unmute prefix', asy
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoMute.MEMO_UNMUTE_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg instanceof Uint8Array &&
wallet.broadcasts[0].msg.length === MemoMute.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
broadcastHex(wallet, 0) === hash20(addr)
},
{ label: 'unmute broadcast conservation and hash160 length' }
)
@@ -17,6 +17,10 @@ const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const FOLLOWEE_ADDRESS = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const FOLLOWEE_HASH160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
function broadcastHex (wallet, index = 0) {
return Buffer.from(wallet.broadcasts[index].msg).toString('hex')
}
function makeBchjs () {
return {
Address: {
@@ -63,8 +67,8 @@ test('follow broadcasts with the Memo follow prefix and hash160 payload', async
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoFollow.MEMO_FOLLOW_PREFIX)
assert.ok(Buffer.isBuffer(wallet.broadcasts[0].msg))
assert.equal(wallet.broadcasts[0].msg.toString('hex'), FOLLOWEE_HASH160)
assert.equal(wallet.broadcasts[0].msg.length, MemoFollow.PK_HASH_LENGTH)
assert.equal(broadcastHex(wallet, 0), FOLLOWEE_HASH160)
})
test('unfollow broadcasts with the Memo unfollow prefix and hash160 payload', async () => {
@@ -75,7 +79,8 @@ test('unfollow broadcasts with the Memo unfollow prefix and hash160 payload', as
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoFollow.MEMO_UNFOLLOW_PREFIX)
assert.equal(wallet.broadcasts[0].msg.toString('hex'), FOLLOWEE_HASH160)
assert.equal(wallet.broadcasts[0].msg.length, MemoFollow.PK_HASH_LENGTH)
assert.equal(broadcastHex(wallet, 0), FOLLOWEE_HASH160)
})
test('follow reflects the new follow state on the profile store', async () => {
+8 -3
View File
@@ -12,6 +12,10 @@ const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const MUTEE_ADDRESS = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const MUTEE_HASH160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
function broadcastHex (wallet, index = 0) {
return Buffer.from(wallet.broadcasts[index].msg).toString('hex')
}
function makeBchjs () {
return {
Address: {
@@ -58,8 +62,8 @@ test('mute broadcasts with the Memo mute prefix and hash160 payload', async () =
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoMute.MEMO_MUTE_PREFIX)
assert.ok(Buffer.isBuffer(wallet.broadcasts[0].msg))
assert.equal(wallet.broadcasts[0].msg.toString('hex'), MUTEE_HASH160)
assert.equal(wallet.broadcasts[0].msg.length, MemoMute.PK_HASH_LENGTH)
assert.equal(broadcastHex(wallet, 0), MUTEE_HASH160)
})
test('unmute broadcasts with the Memo unmute prefix and hash160 payload', async () => {
@@ -70,7 +74,8 @@ test('unmute broadcasts with the Memo unmute prefix and hash160 payload', async
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoMute.MEMO_UNMUTE_PREFIX)
assert.equal(wallet.broadcasts[0].msg.toString('hex'), MUTEE_HASH160)
assert.equal(wallet.broadcasts[0].msg.length, MemoMute.PK_HASH_LENGTH)
assert.equal(broadcastHex(wallet, 0), MUTEE_HASH160)
})
test('mute reflects the new mute state on the profile store', async () => {