mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-22 17:22:01 -07:00
Merge architect branch for binary-payload-broadcast
Follow/mute/unfollow/unmute now broadcast the target's raw 20-byte hash160 as the OP_RETURN payload via a browser-safe Uint8Array (no Node Buffer global), shared through the new MemoStateAction base. By specifier.
This commit is contained in:
@@ -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.
|
||||
@@ -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?$/,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,105 +16,44 @@
|
||||
PK_HASH_LENGTH : size of the followee hash160 in bytes (20)
|
||||
*/
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
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 = Buffer.from(hash160, 'hex')
|
||||
|
||||
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: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
|
||||
|
||||
@@ -16,105 +16,44 @@
|
||||
PK_HASH_LENGTH : size of the mutee hash160 in bytes (20)
|
||||
*/
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
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 = Buffer.from(hash160, 'hex')
|
||||
|
||||
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: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
|
||||
|
||||
@@ -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: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
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user