mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge binary-payload-broadcast coder changes
By refactorer.
This commit is contained in:
@@ -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 |
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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**.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user