From 5602975a372ae00e1964314154bfd1f1379e8154 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 10:54:33 -0700 Subject: [PATCH 1/3] Update briefing and backlog after page-size-50 merge By specifier. --- specifier-prompt.md | 19 +++++++++++++++---- specs/feature-backlog.md | 8 ++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/specifier-prompt.md b/specifier-prompt.md index 56b42b2..40b793f 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -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**. diff --git a/specs/feature-backlog.md b/specs/feature-backlog.md index 565b8d0..24560ae 100644 --- a/specs/feature-backlog.md +++ b/specs/feature-backlog.md @@ -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 From 62e04799c885e0e86070fe5ca4381fe2c42d8804 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 11:07:25 -0700 Subject: [PATCH 2/3] Specify binary hash160 broadcast payload for follow/mute Client follow/mute (and their inverse unmute/unfollow) must broadcast the target's raw 20-byte hash160 as the OP_RETURN payload and must not depend on the Node-only Buffer global. Adds the Binary Payload Broadcast regression spec covering the binary payload for all four actions. By specifier. --- .../specs/binary-payload-broadcast.feature | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 psf-memo-client/specs/binary-payload-broadcast.feature diff --git a/psf-memo-client/specs/binary-payload-broadcast.feature b/psf-memo-client/specs/binary-payload-broadcast.feature new file mode 100644 index 0000000..c21cea9 --- /dev/null +++ b/psf-memo-client/specs/binary-payload-broadcast.feature @@ -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 + Given I open the profile page for the address + 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 + 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 + Given the psf-memo-db API reports that I mute the address + Given I open the profile page for the address + 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 + 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 + Given I open the profile page for the address + 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 + 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 + Given the psf-memo-db API reports that I follow the address + Given I open the profile page for the address + 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 + Then the profile page shows a Follow button + + Examples: + | addr | + | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | From 5e1f4739e38ea3e10c4cd1b86ee0fba4189de360 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 11:13:15 -0700 Subject: [PATCH 3/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 () => {