From 5e1f4739e38ea3e10c4cd1b86ee0fba4189de360 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 11:13:15 -0700 Subject: [PATCH] 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. --- psf-memo-client/acceptance/lib/handlers.js | 40 ++++++++++++++++++- psf-memo-client/src/services/memo-follow.js | 3 +- psf-memo-client/src/services/memo-mute.js | 3 +- .../test/property/follow.property.test.js | 12 ++++-- .../property/mute-services.property.test.js | 12 ++++-- psf-memo-client/test/unit/memo-follow.test.js | 11 +++-- psf-memo-client/test/unit/memo-mute.test.js | 11 +++-- 7 files changed, 74 insertions(+), 18 deletions(-) diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 6637243..c8fdbf2 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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?$/, diff --git a/psf-memo-client/src/services/memo-follow.js b/psf-memo-client/src/services/memo-follow.js index 381f14f..05f2afe 100644 --- a/psf-memo-client/src/services/memo-follow.js +++ b/psf-memo-client/src/services/memo-follow.js @@ -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) diff --git a/psf-memo-client/src/services/memo-mute.js b/psf-memo-client/src/services/memo-mute.js index 892a796..0fda779 100644 --- a/psf-memo-client/src/services/memo-mute.js +++ b/psf-memo-client/src/services/memo-mute.js @@ -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) diff --git a/psf-memo-client/test/property/follow.property.test.js b/psf-memo-client/test/property/follow.property.test.js index b795192..90000d3 100644 --- a/psf-memo-client/test/property/follow.property.test.js +++ b/psf-memo-client/test/property/follow.property.test.js @@ -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' } ) diff --git a/psf-memo-client/test/property/mute-services.property.test.js b/psf-memo-client/test/property/mute-services.property.test.js index 65f6f3e..e9b557d 100644 --- a/psf-memo-client/test/property/mute-services.property.test.js +++ b/psf-memo-client/test/property/mute-services.property.test.js @@ -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' } ) diff --git a/psf-memo-client/test/unit/memo-follow.test.js b/psf-memo-client/test/unit/memo-follow.test.js index 216b07c..4c73272 100644 --- a/psf-memo-client/test/unit/memo-follow.test.js +++ b/psf-memo-client/test/unit/memo-follow.test.js @@ -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 () => { diff --git a/psf-memo-client/test/unit/memo-mute.test.js b/psf-memo-client/test/unit/memo-mute.test.js index 896f70e..7553724 100644 --- a/psf-memo-client/test/unit/memo-mute.test.js +++ b/psf-memo-client/test/unit/memo-mute.test.js @@ -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 () => {