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/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 | 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 () => { 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