From 5e1f4739e38ea3e10c4cd1b86ee0fba4189de360 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 11:13:15 -0700 Subject: [PATCH 1/3] 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 () => { From 53f6b90f56e34848a6710cae1740622fefdaeeff Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 11:25:28 -0700 Subject: [PATCH 2/3] Deduplicate Memo follow/mute onto shared MemoStateAction base Extract the near-identical follow/unfollow and mute/unmute action classes onto a config-driven MemoStateAction base, removing 150 duplicated lines. Preserves public API and static exports. Coder's hexToBytes broadcast stays intact. Refreshed mutation manifests via the tool. By refactorer. --- psf-memo-client/src/services/memo-follow.js | 88 +++------------ psf-memo-client/src/services/memo-mute.js | 88 +++------------ .../src/services/memo-state-action.js | 100 ++++++++++++++++++ 3 files changed, 126 insertions(+), 150 deletions(-) create mode 100644 psf-memo-client/src/services/memo-state-action.js diff --git a/psf-memo-client/src/services/memo-follow.js b/psf-memo-client/src/services/memo-follow.js index 05f2afe..0f5e18a 100644 --- a/psf-memo-client/src/services/memo-follow.js +++ b/psf-memo-client/src/services/memo-follow.js @@ -16,106 +16,44 @@ PK_HASH_LENGTH : size of the followee hash160 in bytes (20) */ -const MemoAction = require('./memo-action') -const { hexToBytes } = require('./hex') +const MemoStateAction = require('./memo-state-action') const MEMO_FOLLOW_PREFIX = '6d06' const MEMO_UNFOLLOW_PREFIX = '6d07' -const PK_HASH_LENGTH = 20 -class MemoFollow extends MemoAction { - static config = { +// Compose the static config for this Memo state action. +function followConfig () { + return { prefix: MEMO_FOLLOW_PREFIX, walletRequiredMsg: 'Memo follow requires a wallet.', lengthMessage: 'Follow address is invalid.', emptyMessage: 'Follow address is required.', lengthCode: 'follow_validation', - validationCode: 'follow_validation' + validationCode: 'follow_validation', + reflectMethod: 'setFollowState' } +} - constructor (deps = {}) { - super(deps) - this.profiles = deps.profiles - } - - // Validate a candidate cash address. Returns { ok: true } or throws a typed - // validation error. - validate (addr) { - if (typeof addr !== 'string' || addr.trim().length === 0) { - const err = new Error(this.emptyMessage) - err.code = this.validationCode - throw err - } - - try { - this._toHash160(addr) - return { ok: true } - } catch (err) { - const validationErr = new Error(`Invalid cash address: ${addr}`) - validationErr.code = this.validationCode - throw validationErr - } - } +class MemoFollow extends MemoStateAction { + static config = followConfig() // Broadcast a Memo follow for the given followee address. async follow (followeeAddr) { - return this._broadcastAction(followeeAddr, MEMO_FOLLOW_PREFIX, true) + return this._setState(followeeAddr, MEMO_FOLLOW_PREFIX, true) } // Broadcast a Memo unfollow for the given followee address. async unfollow (followeeAddr) { - return this._broadcastAction(followeeAddr, MEMO_UNFOLLOW_PREFIX, false) - } - - // Internal: validate, broadcast, and reflect a follow/unfollow action. - async _broadcastAction (followeeAddr, prefix, isFollow) { - if (!this.wallet) { - throw new Error(this.walletRequiredMsg) - } - this._ensureBchjs() - - this.validate(followeeAddr) - - await this.wallet.getUtxos() - - const hash160 = this._toHash160(followeeAddr) - const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160') - - const txid = await this.wallet.sendOpReturn(raw, prefix) - - this.reflect(txid, followeeAddr, isFollow) - - return txid - } - - // Convert a cash address to its 20-byte hash160 hex string using the wallet's - // embedded bch-js. - _toHash160 (addr) { - return this.wallet.bchjs.Address.toHash160(addr) - } - - _ensureBchjs () { - if (!this.wallet.bchjs || typeof this.wallet.bchjs.Address.toHash160 !== 'function') { - throw new Error('Wallet does not expose bch-js Address.toHash160.') - } - } - - // Record the new follow state on the injected profile store when it exposes - // the follow methods. - reflect (txid, followeeAddr, isFollow) { - if (this.profiles && typeof this.profiles.setFollowState === 'function') { - const myAddr = this.wallet?.walletInfo?.cashAddress - this.profiles.setFollowState(myAddr, followeeAddr, isFollow) - } + return this._setState(followeeAddr, MEMO_UNFOLLOW_PREFIX, false) } } MemoFollow.MEMO_FOLLOW_PREFIX = MEMO_FOLLOW_PREFIX MemoFollow.MEMO_UNFOLLOW_PREFIX = MEMO_UNFOLLOW_PREFIX -MemoFollow.PK_HASH_LENGTH = PK_HASH_LENGTH +MemoFollow.PK_HASH_LENGTH = MemoStateAction.PK_HASH_LENGTH module.exports = MemoFollow // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-27T17:51:36.253Z","module_hash":"fc0d7db2a5f7872ae0d9228a19c7314b4f46f7574c53b2a168a62b986c43a31e","functions":[{"id":"func/MemoFollow.constructor","name":"MemoFollow.constructor","line":35,"end_line":38,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoFollow.validate","name":"MemoFollow.validate","line":42,"end_line":57,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoFollow.follow","name":"MemoFollow.follow","line":60,"end_line":62,"hash":"e24f69ef420d1ef625c2d03eae6014c824fdb35438a87e379fdc41747bbd321d"},{"id":"func/MemoFollow.unfollow","name":"MemoFollow.unfollow","line":65,"end_line":67,"hash":"48e93e85645bbcd767467afb807625b4adf6d6076f02fc841c91f58147bb44c7"},{"id":"func/MemoFollow._broadcastAction","name":"MemoFollow._broadcastAction","line":70,"end_line":88,"hash":"3720e2b6d78265383728bd7c5fcafeb018fad9c17a8bede12a6d4ffc3415cd3a"},{"id":"func/MemoFollow._toHash160","name":"MemoFollow._toHash160","line":92,"end_line":94,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoFollow._ensureBchjs","name":"MemoFollow._ensureBchjs","line":96,"end_line":100,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoFollow.reflect","name":"MemoFollow.reflect","line":104,"end_line":109,"hash":"af3f0ead1a9f51724e3c252122b3bf13c5323a3b9da244dee87a34815a90c94c"}]} +// {"version":1,"tested_at":"2026-09-04T18:22:54.857Z","module_hash":"cc36dc8387a54f50e91393bcb10382515e66a0cfc74fb4f51412fac4df580136","functions":[{"id":"func/followConfig","name":"followConfig","line":25,"end_line":35,"hash":"6843e108f72a95c18267d1ecea29fdfbdb0814ce4b5e3672f22df612e6ecaccd"},{"id":"func/MemoFollow.follow","name":"MemoFollow.follow","line":41,"end_line":43,"hash":"82635824a8e221e587b74b22fea454d2f661e599d8c9b9d0d7ec7c14e214cbd7"},{"id":"func/MemoFollow.unfollow","name":"MemoFollow.unfollow","line":46,"end_line":48,"hash":"fa99fa77d68a875092f5aad8323dca42de06dff49af469c6d7db94f7033399b5"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-mute.js b/psf-memo-client/src/services/memo-mute.js index 0fda779..88ada11 100644 --- a/psf-memo-client/src/services/memo-mute.js +++ b/psf-memo-client/src/services/memo-mute.js @@ -16,106 +16,44 @@ PK_HASH_LENGTH : size of the mutee hash160 in bytes (20) */ -const MemoAction = require('./memo-action') -const { hexToBytes } = require('./hex') +const MemoStateAction = require('./memo-state-action') const MEMO_MUTE_PREFIX = '6d16' const MEMO_UNMUTE_PREFIX = '6d17' -const PK_HASH_LENGTH = 20 -class MemoMute extends MemoAction { - static config = { +// Compose the static config for this Memo state action. +function muteConfig () { + return { prefix: MEMO_MUTE_PREFIX, walletRequiredMsg: 'Memo mute requires a wallet.', lengthMessage: 'Mute address is invalid.', emptyMessage: 'Mute address is required.', lengthCode: 'mute_validation', - validationCode: 'mute_validation' + validationCode: 'mute_validation', + reflectMethod: 'setMuteState' } +} - constructor (deps = {}) { - super(deps) - this.profiles = deps.profiles - } - - // Validate a candidate cash address. Returns { ok: true } or throws a typed - // validation error. - validate (addr) { - if (typeof addr !== 'string' || addr.trim().length === 0) { - const err = new Error(this.emptyMessage) - err.code = this.validationCode - throw err - } - - try { - this._toHash160(addr) - return { ok: true } - } catch (err) { - const validationErr = new Error(`Invalid cash address: ${addr}`) - validationErr.code = this.validationCode - throw validationErr - } - } +class MemoMute extends MemoStateAction { + static config = muteConfig() // Broadcast a Memo mute for the given mutee address. async mute (muteeAddr) { - return this._broadcastAction(muteeAddr, MEMO_MUTE_PREFIX, true) + return this._setState(muteeAddr, MEMO_MUTE_PREFIX, true) } // Broadcast a Memo unmute for the given mutee address. async unmute (muteeAddr) { - return this._broadcastAction(muteeAddr, MEMO_UNMUTE_PREFIX, false) - } - - // Internal: validate, broadcast, and reflect a mute/unmute action. - async _broadcastAction (muteeAddr, prefix, isMute) { - if (!this.wallet) { - throw new Error(this.walletRequiredMsg) - } - this._ensureBchjs() - - this.validate(muteeAddr) - - await this.wallet.getUtxos() - - const hash160 = this._toHash160(muteeAddr) - const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160') - - const txid = await this.wallet.sendOpReturn(raw, prefix) - - this.reflect(txid, muteeAddr, isMute) - - return txid - } - - // Convert a cash address to its 20-byte hash160 hex string using the wallet's - // embedded bch-js. - _toHash160 (addr) { - return this.wallet.bchjs.Address.toHash160(addr) - } - - _ensureBchjs () { - if (!this.wallet.bchjs || typeof this.wallet.bchjs.Address.toHash160 !== 'function') { - throw new Error('Wallet does not expose bch-js Address.toHash160.') - } - } - - // Record the new mute state on the injected profile store when it exposes - // the mute methods. - reflect (txid, muteeAddr, isMute) { - if (this.profiles && typeof this.profiles.setMuteState === 'function') { - const myAddr = this.wallet?.walletInfo?.cashAddress - this.profiles.setMuteState(myAddr, muteeAddr, isMute) - } + return this._setState(muteeAddr, MEMO_UNMUTE_PREFIX, false) } } MemoMute.MEMO_MUTE_PREFIX = MEMO_MUTE_PREFIX MemoMute.MEMO_UNMUTE_PREFIX = MEMO_UNMUTE_PREFIX -MemoMute.PK_HASH_LENGTH = PK_HASH_LENGTH +MemoMute.PK_HASH_LENGTH = MemoStateAction.PK_HASH_LENGTH module.exports = MemoMute // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-29T03:07:20.508Z","module_hash":"33469aed44ef4e9225a9ea952c5cd9335f46a0b1c1298525b3fd51ae2f77f817","functions":[{"id":"func/MemoMute.constructor","name":"MemoMute.constructor","line":35,"end_line":38,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoMute.validate","name":"MemoMute.validate","line":42,"end_line":57,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoMute.mute","name":"MemoMute.mute","line":60,"end_line":62,"hash":"60306e96a5b6ef4bac8cf3fc516785880b2ccab835730019bbd4b9bbfa27e464"},{"id":"func/MemoMute.unmute","name":"MemoMute.unmute","line":65,"end_line":67,"hash":"f6476de3d8b62b3f5e864dc83d005452bc9842d9accfed2c64f57082e0fec7b3"},{"id":"func/MemoMute._broadcastAction","name":"MemoMute._broadcastAction","line":70,"end_line":88,"hash":"05f7860e1db59b7611027186c859d790f8d8c74b150daf3b687a8c9dc453f751"},{"id":"func/MemoMute._toHash160","name":"MemoMute._toHash160","line":92,"end_line":94,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoMute._ensureBchjs","name":"MemoMute._ensureBchjs","line":96,"end_line":100,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoMute.reflect","name":"MemoMute.reflect","line":104,"end_line":109,"hash":"6053ec62558bbc19bd5e55b19541a6fe5cde28fe6fbf7c4eede209652e835bc4"}]} +// {"version":1,"tested_at":"2026-09-04T18:22:55.701Z","module_hash":"05cb4f14173db9584533e58f931d775a2505d844f53bd29724935c3de083c40b","functions":[{"id":"func/muteConfig","name":"muteConfig","line":25,"end_line":35,"hash":"f05b5b03c441735322cdb433417ae5351626ed4c7d1db0a94cbf350f6818a605"},{"id":"func/MemoMute.mute","name":"MemoMute.mute","line":41,"end_line":43,"hash":"ad4c0ae3933e732017a59814bc6b9f6e7d263669f59440f1010209403ce0066f"},{"id":"func/MemoMute.unmute","name":"MemoMute.unmute","line":46,"end_line":48,"hash":"758b2676537ce5861f94ca9efff5b98a946bd82c023964e4d0c40f90d0a1261a"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-state-action.js b/psf-memo-client/src/services/memo-state-action.js new file mode 100644 index 0000000..e80b02e --- /dev/null +++ b/psf-memo-client/src/services/memo-state-action.js @@ -0,0 +1,100 @@ +/* + Shared base for Memo follow/unfollow and mute/unmute (state) actions. + + These actions all validate a cash address, convert it to its 20-byte + hash160, and broadcast an OP_RETURN carrying that hash160 payload with the + protocol prefix for a given state transition. On success they reflect the + new state on an injected profile store. + + Subclasses extend MemoAction's config with the state-specific messages and + codes, plus `reflectMethod` naming which profile store method records the + new state, and expose thin methods (follow/unfollow or mute/unmute) that + delegate to the shared `_setState` helper with the appropriate prefix. + + The wallet and an injected profile store are used so this module stays + testable and free of UI/network concerns; environmentally unsuitable I/O + lives behind those small adapter boundaries. +*/ + +const MemoAction = require('./memo-action') +const { hexToBytes } = require('./hex') + +const PK_HASH_LENGTH = 20 + +class MemoStateAction extends MemoAction { + constructor (deps = {}) { + super(deps) + this.profiles = deps.profiles + } + + // Validate a candidate cash address. Returns { ok: true } or throws a typed + // validation error. + validate (addr) { + if (typeof addr !== 'string' || addr.trim().length === 0) { + const err = new Error(this.emptyMessage) + err.code = this.validationCode + throw err + } + + try { + this._toHash160(addr) + return { ok: true } + } catch (err) { + const validationErr = new Error(`Invalid cash address: ${addr}`) + validationErr.code = this.validationCode + throw validationErr + } + } + + // Internal: validate, broadcast, and reflect a state transition. Subclasses + // delegate their public methods here with the transition's prefix. + async _setState (targetAddr, prefix, state) { + if (!this.wallet) { + throw new Error(this.walletRequiredMsg) + } + this._ensureBchjs() + + this.validate(targetAddr) + + await this.wallet.getUtxos() + + const hash160 = this._toHash160(targetAddr) + const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160') + + const txid = await this.wallet.sendOpReturn(raw, prefix) + + this.reflect(txid, targetAddr, state) + + return txid + } + + // Convert a cash address to its 20-byte hash160 hex string using the wallet's + // embedded bch-js. + _toHash160 (addr) { + return this.wallet.bchjs.Address.toHash160(addr) + } + + _ensureBchjs () { + if (!this.wallet.bchjs || typeof this.wallet.bchjs.Address.toHash160 !== 'function') { + throw new Error('Wallet does not expose bch-js Address.toHash160.') + } + } + + // Record the new state on the injected profile store method named by the + // subclass config. + reflect (txid, targetAddr, state) { + const reflectMethod = this.constructor.config.reflectMethod + if (this.profiles && typeof this.profiles[reflectMethod] === 'function') { + const myAddr = this.wallet?.walletInfo?.cashAddress + this.profiles[reflectMethod](myAddr, targetAddr, state) + } + } +} + +MemoStateAction.PK_HASH_LENGTH = PK_HASH_LENGTH + +module.exports = MemoStateAction + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-04T18:22:56.557Z","module_hash":"938e453562e0258b383cbc31f449fccc2e6105c805c97c52fb13bef3ee0fe6b4","functions":[{"id":"func/MemoStateAction.constructor","name":"MemoStateAction.constructor","line":25,"end_line":28,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoStateAction.validate","name":"MemoStateAction.validate","line":32,"end_line":47,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoStateAction._setState","name":"MemoStateAction._setState","line":51,"end_line":69,"hash":"86b976bcbe8532f7bfb9bdc989976f2266bce3d0e8c3a77a1f3e0b8413951684"},{"id":"func/MemoStateAction._toHash160","name":"MemoStateAction._toHash160","line":73,"end_line":75,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoStateAction._ensureBchjs","name":"MemoStateAction._ensureBchjs","line":77,"end_line":81,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoStateAction.reflect","name":"MemoStateAction.reflect","line":85,"end_line":91,"hash":"4ef0ab37e0ab08a681475805c5e53a010b35aa56560bc84f6d2189154ed7fa7d"}]} +// mutate4javascript-manifest-end From 167a64ae6b29c30364cd6e1d8957056d432b61e5 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 11:30:12 -0700 Subject: [PATCH 3/3] Review binary-payload-broadcast: verify MemoStateAction dedup, document Gherkin equivalents By architect. --- .../binary-payload-broadcast-summary.md | 84 +++++++++++++++++++ .../specs/binary-payload-broadcast.feature | 4 + psf-memo-client/src/services/memo-follow.js | 2 +- psf-memo-client/src/services/memo-mute.js | 2 +- .../src/services/memo-state-action.js | 2 +- 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 docs/reviews/binary-payload-broadcast-summary.md diff --git a/docs/reviews/binary-payload-broadcast-summary.md b/docs/reviews/binary-payload-broadcast-summary.md new file mode 100644 index 0000000..aabbbc3 --- /dev/null +++ b/docs/reviews/binary-payload-broadcast-summary.md @@ -0,0 +1,84 @@ +# Architect Review — binary-payload-broadcast + +**Reviewed commits** +- `62e0479` Specify binary hash160 broadcast payload for follow/mute (specifier) +- `5e1f473` Broadcast follow/mute/unfollow/unmute hash160 payloads as Uint8Array (coder) +- `53f6b90` / `8d40f2b` Deduplicate Memo follow/mute onto shared MemoStateAction base (refactorer) + +**Task**: Follow/unfollow and mute/unmute must broadcast the target's raw +20-byte hash160 (not its display-form cash address text) as the OP_RETURN +payload, and must not depend on the Node-only `Buffer` global. The refactorer +also consolidated the previously near-identical `MemoFollow`/`MemoMute` action +classes onto a shared `MemoStateAction` base. + +## Architectural findings + +**UI/Core separation — good.** The follow/mute services remain pure logic over +injected `wallet`/`profiles` adapters. No UI, framework, or IO leaked into the +core; everything network/UI-specific stays behind the small wallet/profile +adapter boundaries. The modules remain fully testable without launching a UI or +network. + +**Dependency rule — good.** `MemoFollow`/`MemoMute` → `MemoStateAction` → +`MemoAction` → `hex`/`utf8`. High-level action semantics depend on low-level +byte/hex helpers through a stable base; the direction is inward. The base +subclasses (`MemoFollow`, `MemoMute`) are thin facades exposing only +`follow`/`unfollow` and `mute`/`unmute`. + +**Information hiding / encapsulation — good.** `MemoStateAction` encapsulates +the shared validate → toHash160 → hexToBytes → sendOpReturn → reflect +transition. Subclasses expose only config (`followConfig`/`muteConfig`) and +their two public methods; static exports and the public API are preserved. The +20-byte length lives on the base as `PK_HASH_LENGTH`, surfaced on both +subclasses. + +**Local code quality.** The refactor is a clean DRY extraction (~150 duplicate +lines removed). Two minor, pre-existing notes (not requiring change): +- Each config carries a `prefix` key that is unused (state actions pass the + prefix explicitly to `_setState`); harmless dead config carried over from the + prior `MemoAction` contract. +- `MemoStateAction.validate` throws a typed error whereas the base + `MemoAction.validate` returns `{ok:false}`; contract difference is intended + and documented in the module header. + +No architectural changes were required. The merged work confirms and preserves +the earlier binary `Uint8Array` broadcast behavior (`hexToBytes`) and the +Buffer-free production source. + +## Verification + +All per-component verification for `psf-memo-client` (the only touched +component) passed. + +- **Unit tests**: 280/280 pass +- **Property tests**: 40/40 pass (run separately, as required) +- **Lint**: clean (`standard`) +- **Acceptance**: all 23 generated feature suites pass, including the new + `binary-payload-broadcast.feature` +- **Language mutation** (`mutate4javascript --mutate-all --max-workers 8`): + - `memo-follow.js`: 2 killed, 0 survived, 0 uncovered + - `memo-mute.js`: 2 killed, 0 survived, 0 uncovered + - `memo-state-action.js` (new base): 5 killed, 0 survived, 0 uncovered + - `hex.js`/`memo-action.js` unchanged and unaffected. +- **DRY** (`dry4javascript`) on `memo-state-action.js`/`memo-follow.js`/ + `memo-mute.js`: no duplicate candidates. + +## Soft Gherkin acceptance mutation (survivors) + +`gherkin-mutator --level soft` over `binary-payload-broadcast.feature`: +4 mutations run, 4 survived, 0 killed, 0 errors. All four are single-character +**case** mutations of the shared example cash address (`q`→`Q`, `x`→`X`, +`c`→`C`, `k`→`K`). Each mutated example is applied consistently on both the +broadcast setup side and the assertion side of its scenario, so the scenario +still passes under the mutation (both sides derive from the same mutated +value). These are **genuine intrinsic equivalents**, not implementation gaps, +and match the documented read-only-payload survivor pattern. Documented here; +no chase performed. + +## Handoffs + +No functional handoffs sent. The work was a review of an already-functional +refactorer merge; my branch adds only the durable review summary plus the +tool-refreshed manifests (mutation `tested_at` timestamps and the new +acceptance-mutation manifest) from the verification runs. Function and module +hashes in the mutation manifests are unchanged, confirming no code drift. diff --git a/psf-memo-client/specs/binary-payload-broadcast.feature b/psf-memo-client/specs/binary-payload-broadcast.feature index c21cea9..49209b3 100644 --- a/psf-memo-client/specs/binary-payload-broadcast.feature +++ b/psf-memo-client/specs/binary-payload-broadcast.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-04T18:29:23.460506768Z","feature_name":"Binary Payload Broadcast","feature_path":"../../psf-memo-client/specs/binary-payload-broadcast.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[]} +# acceptance-mutation-manifest-end + # Scenarios: Binary Payload Broadcast - 1, Binary Payload Broadcast - 2, Binary Payload Broadcast - 3, Binary Payload Broadcast - 4 # # Follow/unfollow and mute/unmute broadcast an OP_RETURN whose payload is the diff --git a/psf-memo-client/src/services/memo-follow.js b/psf-memo-client/src/services/memo-follow.js index 0f5e18a..5f7f069 100644 --- a/psf-memo-client/src/services/memo-follow.js +++ b/psf-memo-client/src/services/memo-follow.js @@ -55,5 +55,5 @@ MemoFollow.PK_HASH_LENGTH = MemoStateAction.PK_HASH_LENGTH module.exports = MemoFollow // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-04T18:22:54.857Z","module_hash":"cc36dc8387a54f50e91393bcb10382515e66a0cfc74fb4f51412fac4df580136","functions":[{"id":"func/followConfig","name":"followConfig","line":25,"end_line":35,"hash":"6843e108f72a95c18267d1ecea29fdfbdb0814ce4b5e3672f22df612e6ecaccd"},{"id":"func/MemoFollow.follow","name":"MemoFollow.follow","line":41,"end_line":43,"hash":"82635824a8e221e587b74b22fea454d2f661e599d8c9b9d0d7ec7c14e214cbd7"},{"id":"func/MemoFollow.unfollow","name":"MemoFollow.unfollow","line":46,"end_line":48,"hash":"fa99fa77d68a875092f5aad8323dca42de06dff49af469c6d7db94f7033399b5"}]} +// {"version":1,"tested_at":"2026-09-04T18:27:18.336Z","module_hash":"cc36dc8387a54f50e91393bcb10382515e66a0cfc74fb4f51412fac4df580136","functions":[{"id":"func/followConfig","name":"followConfig","line":25,"end_line":35,"hash":"6843e108f72a95c18267d1ecea29fdfbdb0814ce4b5e3672f22df612e6ecaccd"},{"id":"func/MemoFollow.follow","name":"MemoFollow.follow","line":41,"end_line":43,"hash":"82635824a8e221e587b74b22fea454d2f661e599d8c9b9d0d7ec7c14e214cbd7"},{"id":"func/MemoFollow.unfollow","name":"MemoFollow.unfollow","line":46,"end_line":48,"hash":"fa99fa77d68a875092f5aad8323dca42de06dff49af469c6d7db94f7033399b5"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-mute.js b/psf-memo-client/src/services/memo-mute.js index 88ada11..d8f7f20 100644 --- a/psf-memo-client/src/services/memo-mute.js +++ b/psf-memo-client/src/services/memo-mute.js @@ -55,5 +55,5 @@ MemoMute.PK_HASH_LENGTH = MemoStateAction.PK_HASH_LENGTH module.exports = MemoMute // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-04T18:22:55.701Z","module_hash":"05cb4f14173db9584533e58f931d775a2505d844f53bd29724935c3de083c40b","functions":[{"id":"func/muteConfig","name":"muteConfig","line":25,"end_line":35,"hash":"f05b5b03c441735322cdb433417ae5351626ed4c7d1db0a94cbf350f6818a605"},{"id":"func/MemoMute.mute","name":"MemoMute.mute","line":41,"end_line":43,"hash":"ad4c0ae3933e732017a59814bc6b9f6e7d263669f59440f1010209403ce0066f"},{"id":"func/MemoMute.unmute","name":"MemoMute.unmute","line":46,"end_line":48,"hash":"758b2676537ce5861f94ca9efff5b98a946bd82c023964e4d0c40f90d0a1261a"}]} +// {"version":1,"tested_at":"2026-09-04T18:27:53.801Z","module_hash":"05cb4f14173db9584533e58f931d775a2505d844f53bd29724935c3de083c40b","functions":[{"id":"func/muteConfig","name":"muteConfig","line":25,"end_line":35,"hash":"f05b5b03c441735322cdb433417ae5351626ed4c7d1db0a94cbf350f6818a605"},{"id":"func/MemoMute.mute","name":"MemoMute.mute","line":41,"end_line":43,"hash":"ad4c0ae3933e732017a59814bc6b9f6e7d263669f59440f1010209403ce0066f"},{"id":"func/MemoMute.unmute","name":"MemoMute.unmute","line":46,"end_line":48,"hash":"758b2676537ce5861f94ca9efff5b98a946bd82c023964e4d0c40f90d0a1261a"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-state-action.js b/psf-memo-client/src/services/memo-state-action.js index e80b02e..7c8cdc2 100644 --- a/psf-memo-client/src/services/memo-state-action.js +++ b/psf-memo-client/src/services/memo-state-action.js @@ -96,5 +96,5 @@ MemoStateAction.PK_HASH_LENGTH = PK_HASH_LENGTH module.exports = MemoStateAction // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-04T18:22:56.557Z","module_hash":"938e453562e0258b383cbc31f449fccc2e6105c805c97c52fb13bef3ee0fe6b4","functions":[{"id":"func/MemoStateAction.constructor","name":"MemoStateAction.constructor","line":25,"end_line":28,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoStateAction.validate","name":"MemoStateAction.validate","line":32,"end_line":47,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoStateAction._setState","name":"MemoStateAction._setState","line":51,"end_line":69,"hash":"86b976bcbe8532f7bfb9bdc989976f2266bce3d0e8c3a77a1f3e0b8413951684"},{"id":"func/MemoStateAction._toHash160","name":"MemoStateAction._toHash160","line":73,"end_line":75,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoStateAction._ensureBchjs","name":"MemoStateAction._ensureBchjs","line":77,"end_line":81,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoStateAction.reflect","name":"MemoStateAction.reflect","line":85,"end_line":91,"hash":"4ef0ab37e0ab08a681475805c5e53a010b35aa56560bc84f6d2189154ed7fa7d"}]} +// {"version":1,"tested_at":"2026-09-04T18:28:28.750Z","module_hash":"938e453562e0258b383cbc31f449fccc2e6105c805c97c52fb13bef3ee0fe6b4","functions":[{"id":"func/MemoStateAction.constructor","name":"MemoStateAction.constructor","line":25,"end_line":28,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoStateAction.validate","name":"MemoStateAction.validate","line":32,"end_line":47,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoStateAction._setState","name":"MemoStateAction._setState","line":51,"end_line":69,"hash":"86b976bcbe8532f7bfb9bdc989976f2266bce3d0e8c3a77a1f3e0b8413951684"},{"id":"func/MemoStateAction._toHash160","name":"MemoStateAction._toHash160","line":73,"end_line":75,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoStateAction._ensureBchjs","name":"MemoStateAction._ensureBchjs","line":77,"end_line":81,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoStateAction.reflect","name":"MemoStateAction.reflect","line":85,"end_line":91,"hash":"4ef0ab37e0ab08a681475805c5e53a010b35aa56560bc84f6d2189154ed7fa7d"}]} // mutate4javascript-manifest-end