Merge binary-payload-broadcast coder changes

By refactorer.
This commit is contained in:
Chris Troutner
2026-09-04 11:13:40 -07:00
10 changed files with 151 additions and 22 deletions
+38 -2
View File
@@ -98,8 +98,12 @@ function makeWallet (address) {
return this.utxos
},
sendOpReturn: async function (msg, prefix, bchOutput = []) {
// Record the broadcast attempt, then fail if configured to do so.
this.broadcasts.push({ msg, prefix, bchOutput })
// Normalize binary payloads to Buffer so assertions can safely use
// toString('hex'), while preserving string payloads unchanged.
const storedMsg = (msg instanceof Uint8Array || ArrayBuffer.isView(msg))
? Buffer.from(msg)
: msg
this.broadcasts.push({ msg: storedMsg, prefix, bchOutput })
if (this.failWith) throw new Error(this.failWith)
return 'aa'.repeat(32)
}
@@ -1751,6 +1755,38 @@ const handlers = [
}
}
},
{
name: 'broadcasts OP_RETURN with Memo binary hash160 payload for address',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo (follow|unfollow|mute|unmute) prefix and the binary hash160 payload for the address (.+)$/,
run (m, example, world) {
const action = m[1]
const addr = resolveParam(m[2], example)
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
const prefix = {
follow: MEMO_FOLLOW_PREFIX,
unfollow: MEMO_UNFOLLOW_PREFIX,
mute: MEMO_MUTE_PREFIX,
unmute: MEMO_UNMUTE_PREFIX
}[action]
if (!prefix) {
throw new Error(`Unknown follow/mute action: ${action}`)
}
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== prefix) {
throw new Error(`Expected Memo ${action} prefix ${prefix}, got "${last.prefix}".`)
}
if (last.msg.length !== 20) {
throw new Error(`Broadcast ${action} payload is not 20 bytes.`)
}
if (last.msg.toString('hex') !== hash160) {
throw new Error(`Broadcast ${action} hash160 did not match ${addr}.`)
}
}
},
{
name: 'API serves topic with post count',
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\d+) posts?$/,
@@ -0,0 +1,54 @@
# 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
# target's raw 20-byte hash160, not its display-form cash address text. This
# regression spec exercises that binary payload on the wire for every action.
# The coder must keep the payload bytes exact and must not surface a broadcast
# error after a successful action.
Feature: Binary Payload Broadcast
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has spendable output to pay the transaction fee
Scenario Outline: Binary Payload Broadcast - 1 clicking Mute broadcasts the binary hash160 payload for the address <addr>
Given I open the profile page for the address <addr>
When I click the Mute button
Then the app broadcasts an OP_RETURN transaction with the Memo mute prefix and the binary hash160 payload for the address <addr>
Then the profile page shows an Unmute button
Examples:
| addr |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
Scenario Outline: Binary Payload Broadcast - 2 clicking Unmute broadcasts the binary hash160 payload for the address <addr>
Given the psf-memo-db API reports that I mute the address <addr>
Given I open the profile page for the address <addr>
When I click the Unmute button
Then the app broadcasts an OP_RETURN transaction with the Memo unmute prefix and the binary hash160 payload for the address <addr>
Then the profile page shows a Mute button
Examples:
| addr |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
Scenario Outline: Binary Payload Broadcast - 3 clicking Follow broadcasts the binary hash160 payload for the address <addr>
Given I open the profile page for the address <addr>
When I click the Follow button
Then the app broadcasts an OP_RETURN transaction with the Memo follow prefix and the binary hash160 payload for the address <addr>
Then the profile page shows an Unfollow button
Examples:
| addr |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
Scenario Outline: Binary Payload Broadcast - 4 clicking Unfollow broadcasts the binary hash160 payload for the address <addr>
Given the psf-memo-db API reports that I follow the address <addr>
Given I open the profile page for the address <addr>
When I click the Unfollow button
Then the app broadcasts an OP_RETURN transaction with the Memo unfollow prefix and the binary hash160 payload for the address <addr>
Then the profile page shows a Follow button
Examples:
| addr |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
+2 -1
View File
@@ -17,6 +17,7 @@
*/
const MemoAction = require('./memo-action')
const { hexToBytes } = require('./hex')
const MEMO_FOLLOW_PREFIX = '6d06'
const MEMO_UNFOLLOW_PREFIX = '6d07'
@@ -78,7 +79,7 @@ class MemoFollow extends MemoAction {
await this.wallet.getUtxos()
const hash160 = this._toHash160(followeeAddr)
const raw = Buffer.from(hash160, 'hex')
const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160')
const txid = await this.wallet.sendOpReturn(raw, prefix)
+2 -1
View File
@@ -17,6 +17,7 @@
*/
const MemoAction = require('./memo-action')
const { hexToBytes } = require('./hex')
const MEMO_MUTE_PREFIX = '6d16'
const MEMO_UNMUTE_PREFIX = '6d17'
@@ -78,7 +79,7 @@ class MemoMute extends MemoAction {
await this.wallet.getUtxos()
const hash160 = this._toHash160(muteeAddr)
const raw = Buffer.from(hash160, 'hex')
const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160')
const txid = await this.wallet.sendOpReturn(raw, prefix)
@@ -24,6 +24,10 @@ const rng = seededRandom(20260830)
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const CHARS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
function broadcastHex (wallet, index = 0) {
return Buffer.from(wallet.broadcasts[index].msg).toString('hex')
}
// Deterministic 20-byte hash160 hex for any input string, mirroring what a
// real wallet's bch-js produces for a valid cash address.
function hash20 (s) {
@@ -86,9 +90,9 @@ test('follow broadcasts exactly one hash160 payload with the follow prefix', asy
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoFollow.MEMO_FOLLOW_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg instanceof Uint8Array &&
wallet.broadcasts[0].msg.length === MemoFollow.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
broadcastHex(wallet, 0) === hash20(addr)
},
{ label: 'follow broadcast conservation and hash160 length' }
)
@@ -104,9 +108,9 @@ test('unfollow broadcasts exactly one hash160 payload with the unfollow prefix',
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoFollow.MEMO_UNFOLLOW_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg instanceof Uint8Array &&
wallet.broadcasts[0].msg.length === MemoFollow.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
broadcastHex(wallet, 0) === hash20(addr)
},
{ label: 'unfollow broadcast conservation and hash160 length' }
)
@@ -24,6 +24,10 @@ const rng = seededRandom(20260830)
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const CHARS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
function broadcastHex (wallet, index = 0) {
return Buffer.from(wallet.broadcasts[index].msg).toString('hex')
}
// Deterministic 20-byte hash160 hex for any input string, mirroring what a
// real wallet's bch-js produces for a valid cash address.
function hash20 (s) {
@@ -86,9 +90,9 @@ test('mute broadcasts exactly one hash160 payload with the mute prefix', async (
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoMute.MEMO_MUTE_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg instanceof Uint8Array &&
wallet.broadcasts[0].msg.length === MemoMute.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
broadcastHex(wallet, 0) === hash20(addr)
},
{ label: 'mute broadcast conservation and hash160 length' }
)
@@ -104,9 +108,9 @@ test('unmute broadcasts exactly one hash160 payload with the unmute prefix', asy
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoMute.MEMO_UNMUTE_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg instanceof Uint8Array &&
wallet.broadcasts[0].msg.length === MemoMute.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
broadcastHex(wallet, 0) === hash20(addr)
},
{ label: 'unmute broadcast conservation and hash160 length' }
)
@@ -17,6 +17,10 @@ const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const FOLLOWEE_ADDRESS = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const FOLLOWEE_HASH160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
function broadcastHex (wallet, index = 0) {
return Buffer.from(wallet.broadcasts[index].msg).toString('hex')
}
function makeBchjs () {
return {
Address: {
@@ -63,8 +67,8 @@ test('follow broadcasts with the Memo follow prefix and hash160 payload', async
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoFollow.MEMO_FOLLOW_PREFIX)
assert.ok(Buffer.isBuffer(wallet.broadcasts[0].msg))
assert.equal(wallet.broadcasts[0].msg.toString('hex'), FOLLOWEE_HASH160)
assert.equal(wallet.broadcasts[0].msg.length, MemoFollow.PK_HASH_LENGTH)
assert.equal(broadcastHex(wallet, 0), FOLLOWEE_HASH160)
})
test('unfollow broadcasts with the Memo unfollow prefix and hash160 payload', async () => {
@@ -75,7 +79,8 @@ test('unfollow broadcasts with the Memo unfollow prefix and hash160 payload', as
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoFollow.MEMO_UNFOLLOW_PREFIX)
assert.equal(wallet.broadcasts[0].msg.toString('hex'), FOLLOWEE_HASH160)
assert.equal(wallet.broadcasts[0].msg.length, MemoFollow.PK_HASH_LENGTH)
assert.equal(broadcastHex(wallet, 0), FOLLOWEE_HASH160)
})
test('follow reflects the new follow state on the profile store', async () => {
+8 -3
View File
@@ -12,6 +12,10 @@ const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const MUTEE_ADDRESS = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const MUTEE_HASH160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
function broadcastHex (wallet, index = 0) {
return Buffer.from(wallet.broadcasts[index].msg).toString('hex')
}
function makeBchjs () {
return {
Address: {
@@ -58,8 +62,8 @@ test('mute broadcasts with the Memo mute prefix and hash160 payload', async () =
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoMute.MEMO_MUTE_PREFIX)
assert.ok(Buffer.isBuffer(wallet.broadcasts[0].msg))
assert.equal(wallet.broadcasts[0].msg.toString('hex'), MUTEE_HASH160)
assert.equal(wallet.broadcasts[0].msg.length, MemoMute.PK_HASH_LENGTH)
assert.equal(broadcastHex(wallet, 0), MUTEE_HASH160)
})
test('unmute broadcasts with the Memo unmute prefix and hash160 payload', async () => {
@@ -70,7 +74,8 @@ test('unmute broadcasts with the Memo unmute prefix and hash160 payload', async
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoMute.MEMO_UNMUTE_PREFIX)
assert.equal(wallet.broadcasts[0].msg.toString('hex'), MUTEE_HASH160)
assert.equal(wallet.broadcasts[0].msg.length, MemoMute.PK_HASH_LENGTH)
assert.equal(broadcastHex(wallet, 0), MUTEE_HASH160)
})
test('mute reflects the new mute state on the profile store', async () => {
+15 -4
View File
@@ -331,6 +331,16 @@ that a single user-facing feature may require specs in more than one component.
and by the acceptance adapter (`acceptance/lib/render-post.js`) under Node.
Spec rendering features against that observable seam (embedded player shown,
raw URL suppressed, surrounding text preserved) rather than against the DOM.
18. **Page size lives in TWO places per page.** Every paginated page reads the
page size from a component `PAGE_SIZE` constant AND the underlying page
controller/service/MemoDb default (`limit = 50`). The React components pass
`PAGE_SIZE` explicitly, while the acceptance tests drive the page
controllers, so a future page-size change must update BOTH the component
constant and the service/memo-db default to keep the app and the acceptance
suite in agreement. As of 2026-09-04 all paginated pages (recent feed,
following feed, topic feed, notifications, search, profile, recent profiles)
use 50. The pure paginated controllers share a `PaginatedPage` base; profile,
search, and recent-profiles gained Previous/Next controls in the same change.
---
@@ -368,8 +378,9 @@ At the end of each session, update this file:
- Note the current `master` HEAD commit.
- State the next feature to work on.
Current `master` HEAD: `b63019c` (merged architect's youtube-embed review;
verified client build OK + 260 unit passing + lint clean + all 21 acceptance
suites pass incl. youtube-embed 1-3; db 331 passing + lint clean for the
carried-in CRAP/DRY refactor).
Current `master` HEAD: `cfe6711` (merged architect's page-size-50 job — every
paginated page now requests 50 items instead of 100, pagination controls added
to search/profile/recent-profiles, controllers refactored onto `PaginatedPage`;
verified client build OK + 280 unit passing + 40 property passing + lint clean +
all 22 acceptance suites pass incl. the new page-size suite).
Next action: **ask the user for the next front-end improvement to spec**.
+8
View File
@@ -31,6 +31,14 @@ focus is **front-end improvements** to `psf-memo-client` (the React SPA).
## Recently completed
- **Page size 50 (2026-09-04):** every paginated page in the client now requests 50
items per page instead of 100 to cut payload size and improve page load times.
Covers the recent feed, following feed, topic feed, notifications, search,
profile, and recent profiles pages. Pagination Previous/Next controls were also
added to the search, profile, and recent-profiles pages (which previously had
none), and the paginated page controllers were refactored onto a shared
`PaginatedPage` base plus `RecentProfilesPage`. Spec:
`psf-memo-client/specs/page-size.feature`. Merged to `master` at `cfe6711`.
- **YouTube embed (2026-09-04):** posts whose text contains a YouTube link
(`youtube.com/watch?v=…` or `youtu.be/…`) render an embedded player instead
of the raw URL; surrounding text is preserved; non-embeddable URLs stay plain