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.
This commit is contained in:
Chris Troutner
2026-09-04 11:25:28 -07:00
parent 8d40f2bdcd
commit 53f6b90f56
3 changed files with 126 additions and 150 deletions
+13 -75
View File
@@ -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
+13 -75
View File
@@ -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
@@ -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