From 5926db1913daf42ac744ab017df16cee832b810e Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 20:52:25 -0700 Subject: [PATCH 1/5] Mark like-count display complete and set next feature to profile text By specifier. --- specifier-prompt.md | 17 +++++++++-------- specs/feature-backlog.md | 28 +++++++++++++++------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/specifier-prompt.md b/specifier-prompt.md index 5b32683..3a4d713 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -167,16 +167,17 @@ Goal: reach feature parity with [memo.cash](https://memo.cash). | Tier | Features | Status | |------|----------|--------| | **P0** | Post, Set name, Reply, Efficient pagination | ✅ shipped | -| **P1** | Like counts (read side) ✅; client display, Set profile text, Set profile picture, Follow/Unfollow | next | +| **P1** | Like counts (read side) ✅; client display ✅; Set profile text, Set profile picture, Follow/Unfollow | next | | **P2** | Topics (list, feed, post, follow/unfollow) | later | | **P3** | Polls (create, add option, vote) | later | | **P4** | Mute / unmute user | later | | **P5** | Send money memo action, MIP-0009 token exchange | later | | **P6** | Repost, ranked feed, notifications, search, tags, following feed | later | -**Suggested next spec:** P1.2 — update the client feed/profile/thread UI to -read `likeCount` from the `psf-memo-db` API instead of defaulting to 0. -The like/tip broadcast UI is already implemented. +**Suggested next spec:** P1.3 — add a "Set Bio" UI on the Account page that +broadcasts the `0x6d05` Set profile text action. The indexer and DB already +read/store profile text; the missing piece is the client write path and the +Account page editor. --- @@ -342,7 +343,7 @@ 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: `4bd4b79` (likeCount read side merged to `master`; -psf-memo-db tests and lint passing). -Next action: **spec P1.2** — update the client feed/profile/thread UI to read -`likeCount` from the API. +Current `master` HEAD: `21373f4` (like-count-display merged to `master`; +client + DB tests, build, and lint passing). +Next action: **spec P1.3** — add a "Set Bio" UI on the Account page that +broadcasts the `0x6d05` Set profile text action. diff --git a/specs/feature-backlog.md b/specs/feature-backlog.md index c5a0264..07aa833 100644 --- a/specs/feature-backlog.md +++ b/specs/feature-backlog.md @@ -120,7 +120,7 @@ adding/editing the client UI. | # | Feature | Memo action | Components | Status | Next work | |---|---------|-------------|------------|--------|-----------| | 1.1 | Like / tip a Memo — read side | `0x6d04` | D | ✅ | `likeCount` returned on `/posts/*` and `/posts/:txid/thread` | -| 1.2 | Like / tip a Memo — client display | `0x6d04` | C | 🟡 partial | Feed/Profile/Thread read `likeCount` from API instead of defaulting to 0 | +| 1.2 | Like / tip a Memo — client display | `0x6d04` | C | ✅ | Feed/Profile/Thread read `likeCount` from API instead of defaulting to 0 | | 1.3 | Set profile text (bio) | `0x6d05` | C, I, D | 🟡 partial | C: add "Set Bio" UI on Account page; I/D already read | | 1.4 | Set profile picture | `0x6d0a` | C, I, D | 🟡 partial | C: add "Set Avatar URL" UI; I/D already read | | 1.5 | Follow a user | `0x6d06` | C, I, D | 🔴 missing | C: follow button on profile; D: follow state + following/followers lists | @@ -129,8 +129,9 @@ adding/editing the client UI. ### Priority order within P1 1. **Like / tip a Memo — read side** ✅ DONE. -2. **Like / tip a Memo — client display** — the API now returns `likeCount`; - update the feed/profile/thread UI to read it instead of defaulting to 0. +2. **Like / tip a Memo — client display** ✅ DONE. The feed, profile, and + thread views now read `likeCount` from the API (profile shows a read-only + like button). 3. **Set profile text** — simple text broadcast + Account page UI. 4. **Set profile picture** — URL broadcast + Account page UI. 5. **Follow / Unfollow user** — social graph; enables the following feed later. @@ -144,7 +145,8 @@ adding/editing the client UI. - `psf-memo-db` aggregates `likesDb` into per-post `likeCount` in `/posts/recent`, `/posts/by/:addr`, and `/posts/:txid/thread` responses. - The client already has `MemoLike`, `LikeTipPage`, `LikeButton`, and - `LikeTipModal`; it only needs to read `likeCount` from the feed API. + `LikeTipModal`; the feed/profile/thread views now read `likeCount` from the + API (profile renders a read-only `LikeButton`). --- @@ -211,16 +213,16 @@ Polls require a new data model and rendering. The indexer has no handler yet. ## Suggested first spec for the next session -**Like counts on posts** (P1.1, read side): -- `psf-memo-db`: add a `LikeQuery` adapter that builds a `likeCount` map from - `likesDb`, inject it into `ListRecentPosts`, `ListPostsByAddr`, and - `GetPostThread`, and return `likeCount` on every post object. -- `psf-memo-client`: read `likeCount` from the feed/profile API instead of - starting at 0. -- No indexer changes needed; the `like` handler already writes `likesDb`. +**Set profile text (bio)** (P1.3): +- `psf-memo-client`: add a "Set Bio" UI on the Account page that broadcasts the + `0x6d05` Set profile text action via `minimal-slp-wallet.sendOpReturn()`. +- `psf-memo-indexer` and `psf-memo-db` already read/store profile text, so this + is primarily a client broadcast + Account page UI feature. +- The profile page already renders `profileText` from the API; the missing piece + is the write path (broadcast) and the Account page editor. -This closes the loop on the already-implemented like/tip broadcast feature and -is the smallest end-to-end win toward memo.cash parity. +This is the next smallest end-to-end win toward memo.cash parity after the +like-count read/display loop closed. --- From 7e5b704a373adc2cc38ddeca3cf32fb854d6c5f9 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 20:55:00 -0700 Subject: [PATCH 2/5] Switching back to deepseek for specifier --- swarmforge/swarmforge.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swarmforge/swarmforge.conf b/swarmforge/swarmforge.conf index 42b3197..8574f47 100644 --- a/swarmforge/swarmforge.conf +++ b/swarmforge/swarmforge.conf @@ -8,7 +8,7 @@ # - refactorer → qwen-token-plan/qwen3.7-max (familia Qwen) # - architect → qwen-token-plan/glm-5.2 (familia GLM) # Verificar ids con: pi --list-models -window specifier pi master --model kimi-k2.7-code:cloud +window specifier pi master --model deepseek-v4-flash:0731-cloud window coder pi coder --model kimi-k2.7-code:cloud window refactorer pi refactorer batch --model deepseek-v4-flash:0731-cloud window architect pi architect batch --model deepseek-v4-flash:0731-cloud From fcab46d70620b4e1c2cde1b4275af4cf13b80545 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 27 Aug 2026 07:44:41 -0700 Subject: [PATCH 3/5] Add Gherkin spec for Set Bio (0x6d05) client write path By specifier. --- psf-memo-client/specs/set-bio.feature | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 psf-memo-client/specs/set-bio.feature diff --git a/psf-memo-client/specs/set-bio.feature b/psf-memo-client/specs/set-bio.feature new file mode 100644 index 0000000..21a9933 --- /dev/null +++ b/psf-memo-client/specs/set-bio.feature @@ -0,0 +1,60 @@ +# Scenarios: Set Bio - 1, Set Bio - 2, Set Bio - 3, Set Bio - 4, Set Bio - 5 +Feature: Set Bio + + Background: + Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d + Given the wallet has spendable output to pay the transaction fee + + Scenario Outline: Set Bio - 1 a valid bio is broadcast and the user lands on the account page + Given I navigate to the path /memo/set-bio + When I type a bio with the text "" + When I submit the bio + Then the app broadcasts an OP_RETURN transaction with the Memo set-profile prefix + Then I navigate to the path /account + Then the account page shows my bio as "" + + Examples: + | text | + | Building the future on Bitcoin Cash | + | a longer bio with spaces and punctuation | + + Scenario Outline: Set Bio - 2 an empty bio is rejected on the set bio page + Given I navigate to the path /memo/set-bio + When I type a bio with the text "" + When I submit the bio + Then the set bio page shows a validation error + Then the app does not broadcast any transaction + + Examples: + | text | + | | + + Scenario Outline: Set Bio - 3 an over-long bio is rejected on the set bio page + Given I navigate to the path /memo/set-bio + When I type a bio with the text "" + When I submit the bio + Then the set bio page shows a length error + Then the app does not broadcast any transaction + + Examples: + | text | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | + | 😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀 | + + Scenario Outline: Set Bio - 4 the byte counter counts down from the bio limit + Given I navigate to the path /memo/set-bio + When I type a bio with the text "" + Then the set bio page shows a remaining byte count of + + Examples: + | text | count | + | | 217 | + | hello | 212 | + | é | 215 | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 | + + Scenario: Set Bio - 5 the account page links to the set bio page + Given I navigate to the path /account + Then the account page shows a Set Bio button + When I click the Set Bio button + Then I navigate to the path /memo/set-bio From 01a16769bd804a5004f6e4cc3fe4e8e847b30774 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 27 Aug 2026 07:50:33 -0700 Subject: [PATCH 4/5] Implement Set Bio (0x6d05) client write path Add Memo set-bio action, Set Bio page controller, Set Bio React view, account bio display, and acceptance step handlers. Includes unit tests. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 108 ++++++++++++++- .../src/components/app-body/account/index.js | 32 ++++- .../src/components/app-body/index.js | 2 + .../src/components/app-body/set-bio/index.js | 95 +++++++++++++ psf-memo-client/src/services/account-page.js | 22 +++ psf-memo-client/src/services/memo-set-bio.js | 62 +++++++++ psf-memo-client/src/services/profiles.js | 11 ++ psf-memo-client/src/services/set-bio-page.js | 54 ++++++++ .../test/unit/account-page.test.js | 105 +++++++++++++++ .../test/unit/memo-set-bio.test.js | 125 ++++++++++++++++++ psf-memo-client/test/unit/profiles.test.js | 71 ++++++++++ .../test/unit/set-bio-page.test.js | 110 +++++++++++++++ 12 files changed, 789 insertions(+), 8 deletions(-) create mode 100644 psf-memo-client/src/components/app-body/set-bio/index.js create mode 100644 psf-memo-client/src/services/memo-set-bio.js create mode 100644 psf-memo-client/src/services/set-bio-page.js create mode 100644 psf-memo-client/test/unit/account-page.test.js create mode 100644 psf-memo-client/test/unit/memo-set-bio.test.js create mode 100644 psf-memo-client/test/unit/profiles.test.js create mode 100644 psf-memo-client/test/unit/set-bio-page.test.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 99765af..5a7c040 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -25,6 +25,8 @@ const MemoReply = require('../../src/services/memo-reply') const ReplyThreadPage = require('../../src/services/reply-thread-page') const MemoSetName = require('../../src/services/memo-set-name') const SetNamePage = require('../../src/services/set-name-page') +const MemoSetBio = require('../../src/services/memo-set-bio') +const SetBioPage = require('../../src/services/set-bio-page') const AccountPage = require('../../src/services/account-page') const MemoLike = require('../../src/services/memo-like') const LikeTipPage = require('../../src/services/like-tip-page') @@ -35,6 +37,7 @@ const ThreadPage = require('../../src/services/thread-page') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX +const MEMO_SET_BIO_PREFIX = MemoSetBio.MEMO_SET_BIO_PREFIX const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX // Default author address used by Gherkin steps that refer to "the author address". @@ -73,13 +76,17 @@ function makeFeed () { } } -// A fake profile store recording display names set for addresses. +// A fake profile store recording display names and bios set for addresses. function makeProfiles () { const names = {} + const bios = {} return { names, + bios, setName: (addr, name) => { names[addr] = name }, - getName: (addr) => names[addr] || null + getName: (addr) => names[addr] || null, + setBio: (addr, bio) => { bios[addr] = bio }, + getBio: (addr) => bios[addr] || null } } @@ -177,6 +184,14 @@ function createWorld () { memoSetName, navigate: (path) => { world.currentPath = path } }) + + // The Set Bio Page controller shares the same profile store. + const memoSetBio = new MemoSetBio({ wallet, profiles }) + world.setBioPage = new SetBioPage({ + memoSetBio, + navigate: (path) => { world.currentPath = path } + }) + world.accountPage = new AccountPage({ wallet, profiles, @@ -313,6 +328,17 @@ const handlers = [ world.newPage.setInput(example[param]) } }, + { + name: 'type bio text', + pattern: /^I type a bio with the text "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + world.setBioPage.setInput(example[param]) + } + }, { name: 'type name text', pattern: /^I type a name with the text "<([A-Za-z0-9_]+)>"$/, @@ -331,6 +357,13 @@ const handlers = [ await world.newPage.submit() } }, + { + name: 'submit bio', + pattern: /^I submit the bio$/, + async run (m, example, world) { + await world.setBioPage.submit() + } + }, { name: 'submit name', pattern: /^I submit the name$/, @@ -442,6 +475,13 @@ const handlers = [ }) } }, + { + name: 'click Set Bio button', + pattern: /^I click the Set Bio button$/, + run (m, example, world) { + world.accountPage.clickSetBio() + } + }, { name: 'click Set Name button', pattern: /^I click the Set Name button$/, @@ -483,6 +523,23 @@ const handlers = [ } } }, + { + name: 'broadcasts OP_RETURN with Memo set-profile prefix', + pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-profile prefix$/, + run (m, example, world) { + 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 !== MEMO_SET_BIO_PREFIX) { + throw new Error(`Expected Memo set-profile prefix ${MEMO_SET_BIO_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== world.setBioPage.input) { + throw new Error('Broadcast bio text did not match the typed bio.') + } + } + }, { name: 'broadcasts OP_RETURN with Memo reply prefix', pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo reply prefix$/, @@ -594,6 +651,17 @@ const handlers = [ } } }, + { + name: 'set bio page shows validation/length error', + pattern: /^the set bio page shows a (validation|length) error$/, + run (m, example, world) { + const kind = m[1] + const expectedCode = kind === 'validation' ? 'bio_validation' : 'bio_length' + if (world.setBioPage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.setBioPage.submitError}.`) + } + } + }, { name: 'remaining character count', pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/, @@ -624,6 +692,21 @@ const handlers = [ } } }, + { + name: 'set bio remaining byte count', + pattern: /^the set bio page shows a remaining byte count of <([A-Za-z0-9_]+)>$/, + run (m, example, world) { + const param = m[1] + const expected = parseInt(example[param], 10) + if (Number.isNaN(expected)) { + throw new Error(`Invalid expected count for "${param}".`) + } + const actual = world.setBioPage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`) + } + } + }, { name: 'app does not broadcast any transaction', pattern: /^(?:the wallet|the app) does not broadcast any transaction$/, @@ -645,6 +728,18 @@ const handlers = [ } } }, + { + name: 'account page shows bio', + pattern: /^the account page shows my bio as "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expected = example[param] + const actual = world.accountPage.getBio() + if (actual !== expected) { + throw new Error(`Expected account bio "${expected}", got "${actual}".`) + } + } + }, { name: 'account page shows Set Name button', pattern: /^the account page shows a Set Name button$/, @@ -654,6 +749,15 @@ const handlers = [ } } }, + { + name: 'account page shows Set Bio button', + pattern: /^the account page shows a Set Bio button$/, + run (m, example, world) { + if (!world.accountPage.hasSetBioButton()) { + throw new Error('Account page does not show a Set Bio button.') + } + } + }, { name: 'wallet has spendable balance', pattern: /^the wallet has a spendable balance of (.+) sats$/, diff --git a/psf-memo-client/src/components/app-body/account/index.js b/psf-memo-client/src/components/app-body/account/index.js index ebd59a8..bc1b20b 100644 --- a/psf-memo-client/src/components/app-body/account/index.js +++ b/psf-memo-client/src/components/app-body/account/index.js @@ -20,28 +20,33 @@ function Account (props) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [name, setName] = useState(null) + const [bio, setBio] = useState(null) const wallet = appData?.wallet const address = wallet?.walletInfo?.cashAddress || '' useEffect(() => { - const loadName = async () => { + const loadAccount = async () => { setLoading(true) setError(null) try { const memoDb = new MemoDb() - const profile = await memoDb.getName(address) - setName(profile?.name || null) + const [profile, nameDoc] = await Promise.all([ + memoDb.getProfile(address), + memoDb.getName(address) + ]) + setBio(profile?.text || null) + setName(nameDoc?.name || null) } catch (err) { - setError(err.message || 'Failed to load name') + setError(err.message || 'Failed to load account') } setLoading(false) } if (address) { - loadName() + loadAccount() } else { setLoading(false) } @@ -53,7 +58,8 @@ function Account (props) { navigate }) - const displayName = name || accountPage.getName() || truncateAddr(address, 24) + const displayName = accountPage.getName() || name || truncateAddr(address, 24) + const displayBio = accountPage.getBio() || bio || '' return ( @@ -77,6 +83,10 @@ function Account (props) { Name: {displayName}

+

+ Bio: + {displayBio || No bio set} +

Address: {address} @@ -85,11 +95,21 @@ function Account (props) { {accountPage.hasSetNameButton() && ( )} + + {accountPage.hasSetBioButton() && ( + + )} )} diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index 78241d3..87a2d43 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -28,6 +28,7 @@ import RecentPosts from './posts' import NewPost from './new-post' import Profile from './profile' import SetName from './set-name' +import SetBio from './set-bio' import Account from './account' function AppBody (props) { @@ -47,6 +48,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/psf-memo-client/src/components/app-body/set-bio/index.js b/psf-memo-client/src/components/app-body/set-bio/index.js new file mode 100644 index 0000000..2d2f245 --- /dev/null +++ b/psf-memo-client/src/components/app-body/set-bio/index.js @@ -0,0 +1,95 @@ +/* + Set Bio view: compose and broadcast a Memo profile text, with a byte counter + that counts down from the bio limit. On success the user is navigated to the + account page. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Form, Button } from 'react-bootstrap' +import { useNavigate } from 'react-router-dom' + +// Local libraries +import MemoSetBio from '../../../services/memo-set-bio' +import SetBioPage from '../../../services/set-bio-page' +import { byteLength } from '../../../services/utf8' + +function SetBio (props) { + const { appData } = props + const navigate = useNavigate() + + const maxBytes = MemoSetBio.MAX_BIO_BYTES + const [input, setInput] = useState('') + const [err, setErr] = useState('') + const [settingBio, setSettingBio] = useState(false) + + const remaining = maxBytes - byteLength(input) + + async function handleSubmit (event) { + event.preventDefault() + setErr('') + setSettingBio(true) + + try { + const memoSetBio = new MemoSetBio({ wallet: appData?.wallet, profiles: appData?.profiles }) + const page = new SetBioPage({ memoSetBio, navigate }) + page.setInput(input) + + const result = await page.submit() + if (!result.ok) { + if (result.error === 'bio_length') { + setErr(`Bio is too long. Maximum is ${maxBytes} bytes.`) + } else if (result.error === 'bio_validation') { + setErr('Bio must not be empty.') + } else if (result.message) { + setErr(`Failed to broadcast: ${result.message}`) + } else { + setErr('Failed to set bio.') + } + } + // On success page.submit() navigated to the account page. + } catch (submitErr) { + setErr(submitErr.message) + } finally { + setSettingBio(false) + } + } + + return ( + + + +

+

Set Bio

+

Set your profile bio and publish it to Bitcoin Cash.

+
+ +
+ + Bio + setInput(e.target.value)} + placeholder='Write a short bio...' + /> + + +

+ {remaining} bytes remaining +

+ + {err &&

{err}

} + + +
+ + +
+ ) +} + +export default SetBio diff --git a/psf-memo-client/src/services/account-page.js b/psf-memo-client/src/services/account-page.js index e8182a1..172ef56 100644 --- a/psf-memo-client/src/services/account-page.js +++ b/psf-memo-client/src/services/account-page.js @@ -12,6 +12,7 @@ */ const SET_NAME_PATH = '/memo/set-name' +const SET_BIO_PATH = '/memo/set-bio' const ACCOUNT_PATH = '/account' class AccountPage { @@ -36,18 +37,39 @@ class AccountPage { return this.profiles.getName(address) } + // The current bio for the authenticated address. Falls back to null when no + // wallet, profile store, or stored bio exists. + getBio () { + const address = this.getAddress() + if (!address || !this.profiles || typeof this.profiles.getBio !== 'function') { + return null + } + return this.profiles.getBio(address) + } + // Whether the account page exposes a Set Name button. hasSetNameButton () { return true } + // Whether the account page exposes a Set Bio button. + hasSetBioButton () { + return true + } + // Click the Set Name button: navigate to the set-name page. clickSetName () { this.navigate(SET_NAME_PATH) } + + // Click the Set Bio button: navigate to the set-bio page. + clickSetBio () { + this.navigate(SET_BIO_PATH) + } } AccountPage.SET_NAME_PATH = SET_NAME_PATH +AccountPage.SET_BIO_PATH = SET_BIO_PATH AccountPage.ACCOUNT_PATH = ACCOUNT_PATH module.exports = AccountPage diff --git a/psf-memo-client/src/services/memo-set-bio.js b/psf-memo-client/src/services/memo-set-bio.js new file mode 100644 index 0000000..b9a8bde --- /dev/null +++ b/psf-memo-client/src/services/memo-set-bio.js @@ -0,0 +1,62 @@ +/* + Memo set-bio behavior: compose, validate, and broadcast a Memo "set profile + text" message. + + A Memo set-bio transaction is an OP_RETURN Bitcoin Cash transaction carrying + the Memo set-profile protocol prefix (0x6d05) followed by the bio text. + Broadcasting is done through a wallet that exposes the minimal-slp-wallet + adapter surface (walletInfo, getUtxos(), sendOpReturn()). + + The wallet and profiles store are injected so this module stays testable and + free of network/UI concerns; environmentally unsuitable I/O lives behind those + small adapter boundaries. + + Constants + MEMO_SET_BIO_PREFIX : hex prefix for the Memo "set profile text" action (0x6d05) + MAX_BIO_BYTES : maximum allowed bio length (217 bytes per memo.sv) +*/ + +const MemoAction = require('./memo-action') +const { byteLength } = require('./utf8') + +const MEMO_SET_BIO_PREFIX = '6d05' +const MAX_BIO_BYTES = 217 + +class MemoSetBio extends MemoAction { + static config = { + prefix: MEMO_SET_BIO_PREFIX, + walletRequiredMsg: 'Memo set bio requires a wallet.', + lengthMessage: `Bio is too long. Maximum is ${MAX_BIO_BYTES} bytes.`, + emptyMessage: 'Bio must not be empty.', + lengthCode: 'bio_length', + validationCode: 'bio_validation' + } + + constructor (deps = {}) { + super(deps) + this.profiles = deps.profiles + } + + // A bio is over-length when it exceeds the byte limit. + isTooLong (bio) { + return byteLength(bio) > MAX_BIO_BYTES + } + + // Compose and broadcast a Memo set-bio transaction for the given bio. + // Resolves with the transaction id, or rejects with a typed error. + async setBio (bio) { + return this.broadcast(bio) + } + + // Record the new bio on the injected profile store when one is present. + reflect (txid, bio) { + if (this.profiles && typeof this.profiles.setBio === 'function') { + this.profiles.setBio(this.wallet.walletInfo.cashAddress, bio) + } + } +} + +MemoSetBio.MEMO_SET_BIO_PREFIX = MEMO_SET_BIO_PREFIX +MemoSetBio.MAX_BIO_BYTES = MAX_BIO_BYTES + +module.exports = MemoSetBio diff --git a/psf-memo-client/src/services/profiles.js b/psf-memo-client/src/services/profiles.js index 7334edc..540bad9 100644 --- a/psf-memo-client/src/services/profiles.js +++ b/psf-memo-client/src/services/profiles.js @@ -12,6 +12,7 @@ class Profiles { constructor () { this.names = new Map() + this.bios = new Map() } setName (addr, name) { @@ -23,6 +24,16 @@ class Profiles { if (!addr) return null return this.names.get(addr) || null } + + setBio (addr, bio) { + if (!addr) return + this.bios.set(addr, bio) + } + + getBio (addr) { + if (!addr) return null + return this.bios.get(addr) || null + } } module.exports = Profiles diff --git a/psf-memo-client/src/services/set-bio-page.js b/psf-memo-client/src/services/set-bio-page.js new file mode 100644 index 0000000..aa07c5c --- /dev/null +++ b/psf-memo-client/src/services/set-bio-page.js @@ -0,0 +1,54 @@ +/* + Set Bio Page behavior: compose and broadcast a Memo profile text, with a + byte counter that counts down from the bio limit. + + This is the testable controller behind the React "Set Bio" page. It wraps + the Memo set-bio behavior (src/services/memo-set-bio.js) and adds page-level + concerns: holding the current input, computing the remaining byte count, + surfacing validation/length errors, and navigating to the account page after + a successful broadcast. + + The memoSetBio and navigate concerns are injected so this module stays free + of UI/network concerns; environmentally unsuitable I/O lives behind those small + adapter boundaries. +*/ + +const PageController = require('./page-controller') +const MemoSetBio = require('./memo-set-bio') +const { byteLength } = require('./utf8') + +const SET_BIO_PATH = '/memo/set-bio' +const ACCOUNT_PATH = '/account' + +class SetBioPage extends PageController { + constructor (deps = {}) { + super(deps) + this.memoSetBio = deps.memoSetBio || null + this.settingBio = false + this.successPath = ACCOUNT_PATH + this.validationCodes = ['bio_validation', 'bio_length'] + } + + // Bytes remaining before the bio limit is reached. + remainingCount () { + return MemoSetBio.MAX_BIO_BYTES - byteLength(this.input) + } + + // Set the in-flight setting-bio flag. + _setBusy (value) { + this.settingBio = value + } + + // Run the memo set-bio action for the current input. + async _perform (input) { + if (!this.memoSetBio) { + throw new Error('Set bio requires a memo set-bio handler.') + } + return this.memoSetBio.setBio(input) + } +} + +SetBioPage.SET_BIO_PATH = SET_BIO_PATH +SetBioPage.ACCOUNT_PATH = ACCOUNT_PATH + +module.exports = SetBioPage diff --git a/psf-memo-client/test/unit/account-page.test.js b/psf-memo-client/test/unit/account-page.test.js new file mode 100644 index 0000000..78cb1ac --- /dev/null +++ b/psf-memo-client/test/unit/account-page.test.js @@ -0,0 +1,105 @@ +/* + Unit tests for the account page behavior. + + The account page exposes the authenticated wallet's display name and bio, + along with buttons that navigate to the set-name and set-bio pages. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const AccountPage = require('../../src/services/account-page') + +function makeProfiles () { + const names = {} + const bios = {} + return { + setName: (addr, name) => { names[addr] = name }, + getName: (addr) => names[addr] || null, + setBio: (addr, bio) => { bios[addr] = bio }, + getBio: (addr) => bios[addr] || null + } +} + +function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { + return { walletInfo: { cashAddress: address } } +} + +test('getName returns the stored display name', () => { + const profiles = makeProfiles() + const wallet = makeWallet() + const page = new AccountPage({ wallet, profiles }) + + profiles.setName(wallet.walletInfo.cashAddress, 'trout') + + assert.equal(page.getName(), 'trout') +}) + +test('getName returns null without a wallet', () => { + const profiles = makeProfiles() + const page = new AccountPage({ profiles }) + + assert.equal(page.getName(), null) +}) + +test('getName returns null without a profile store', () => { + const wallet = makeWallet() + const page = new AccountPage({ wallet }) + + assert.equal(page.getName(), null) +}) + +test('getBio returns the stored bio', () => { + const profiles = makeProfiles() + const wallet = makeWallet() + const page = new AccountPage({ wallet, profiles }) + + profiles.setBio(wallet.walletInfo.cashAddress, 'Building on BCH') + + assert.equal(page.getBio(), 'Building on BCH') +}) + +test('getBio returns null without a wallet', () => { + const profiles = makeProfiles() + const page = new AccountPage({ profiles }) + + assert.equal(page.getBio(), null) +}) + +test('getBio returns null without a profile store', () => { + const wallet = makeWallet() + const page = new AccountPage({ wallet }) + + assert.equal(page.getBio(), null) +}) + +test('hasSetNameButton is true', () => { + const page = new AccountPage({}) + + assert.equal(page.hasSetNameButton(), true) +}) + +test('hasSetBioButton is true', () => { + const page = new AccountPage({}) + + assert.equal(page.hasSetBioButton(), true) +}) + +test('clickSetName navigates to the set-name page', () => { + const navigated = [] + const page = new AccountPage({ navigate: (path) => navigated.push(path) }) + + page.clickSetName() + + assert.deepEqual(navigated, [AccountPage.SET_NAME_PATH]) +}) + +test('clickSetBio navigates to the set-bio page', () => { + const navigated = [] + const page = new AccountPage({ navigate: (path) => navigated.push(path) }) + + page.clickSetBio() + + assert.deepEqual(navigated, [AccountPage.SET_BIO_PATH]) +}) diff --git a/psf-memo-client/test/unit/memo-set-bio.test.js b/psf-memo-client/test/unit/memo-set-bio.test.js new file mode 100644 index 0000000..2608261 --- /dev/null +++ b/psf-memo-client/test/unit/memo-set-bio.test.js @@ -0,0 +1,125 @@ +/* + Unit tests for the Memo set-bio behavior. + + The set-bio action validates that the bio is non-empty and within the + 217-byte Memo protocol limit, then broadcasts it with the 0x6d05 prefix. + A successful broadcast is reflected on the injected profile store. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const MemoSetBio = require('../../src/services/memo-set-bio') + +function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { + return { + walletInfo: { cashAddress: address }, + broadcasts: [], + async getUtxos () { + return [] + }, + async sendOpReturn (msg, prefix) { + this.broadcasts.push({ msg, prefix }) + return 'aa'.repeat(32) + } + } +} + +function makeProfiles () { + const bios = {} + return { + bios, + setBio: (addr, bio) => { bios[addr] = bio }, + getBio: (addr) => bios[addr] || null + } +} + +test('setBio broadcasts with the Memo set-profile prefix', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + await memoSetBio.setBio('Building on BCH') + + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, MemoSetBio.MEMO_SET_BIO_PREFIX) + assert.equal(wallet.broadcasts[0].msg, 'Building on BCH') +}) + +test('setBio reflects the bio on the profile store', async () => { + const wallet = makeWallet() + const profiles = makeProfiles() + const memoSetBio = new MemoSetBio({ wallet, profiles }) + + await memoSetBio.setBio('Building on BCH') + + assert.equal(profiles.getBio(wallet.walletInfo.cashAddress), 'Building on BCH') +}) + +test('setBio rejects an empty bio', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + await assert.rejects( + () => memoSetBio.setBio(''), + { code: 'bio_validation', message: /Bio must not be empty/ } + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('setBio rejects a bio that exceeds the byte limit', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + // 218 ASCII bytes is one byte over the 217 limit. + const tooLong = 'a'.repeat(MemoSetBio.MAX_BIO_BYTES + 1) + + await assert.rejects( + () => memoSetBio.setBio(tooLong), + { code: 'bio_length', message: /Bio is too long/ } + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('setBio accepts a bio exactly at the byte limit', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + const exactly = 'a'.repeat(MemoSetBio.MAX_BIO_BYTES) + + await memoSetBio.setBio(exactly) + + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].msg, exactly) +}) + +test('setBio counts bytes, not characters', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + // A single multi-byte character should be counted by byte length. + await assert.rejects( + () => memoSetBio.setBio('😀'.repeat(Math.ceil(MemoSetBio.MAX_BIO_BYTES / 4) + 1)), + { code: 'bio_length' } + ) +}) + +test('setBio requires a wallet', async () => { + const memoSetBio = new MemoSetBio({}) + + await assert.rejects( + () => memoSetBio.setBio('Building on BCH'), + /Memo set bio requires a wallet/ + ) +}) + +test('setBio surfaces a broadcast failure', async () => { + const wallet = makeWallet() + wallet.sendOpReturn = async () => { throw new Error('broadcast failed') } + const memoSetBio = new MemoSetBio({ wallet }) + + await assert.rejects( + () => memoSetBio.setBio('Building on BCH'), + /broadcast failed/ + ) +}) diff --git a/psf-memo-client/test/unit/profiles.test.js b/psf-memo-client/test/unit/profiles.test.js new file mode 100644 index 0000000..9b207ec --- /dev/null +++ b/psf-memo-client/test/unit/profiles.test.js @@ -0,0 +1,71 @@ +/* + Unit tests for the in-memory profile store. + + The profile store keeps display names and bios indexed by BCH cash address + so that pages stay in sync immediately after a broadcast. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const Profiles = require('../../src/services/profiles') + +test('setName stores and getName retrieves a display name', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + profiles.setName(addr, 'trout') + + assert.equal(profiles.getName(addr), 'trout') +}) + +test('getName returns null for an unknown address', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + assert.equal(profiles.getName(addr), null) +}) + +test('setName with no address does nothing', () => { + const profiles = new Profiles() + + profiles.setName('', 'trout') + + assert.equal(profiles.getName(''), null) +}) + +test('setBio stores and getBio retrieves a bio', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + profiles.setBio(addr, 'Building on BCH') + + assert.equal(profiles.getBio(addr), 'Building on BCH') +}) + +test('getBio returns null for an unknown address', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + assert.equal(profiles.getBio(addr), null) +}) + +test('setBio with no address does nothing', () => { + const profiles = new Profiles() + + profiles.setBio('', 'Building on BCH') + + assert.equal(profiles.getBio(''), null) +}) + +test('name and bio storage are independent', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + profiles.setName(addr, 'trout') + profiles.setBio(addr, 'Building on BCH') + + assert.equal(profiles.getName(addr), 'trout') + assert.equal(profiles.getBio(addr), 'Building on BCH') +}) diff --git a/psf-memo-client/test/unit/set-bio-page.test.js b/psf-memo-client/test/unit/set-bio-page.test.js new file mode 100644 index 0000000..fbff4ee --- /dev/null +++ b/psf-memo-client/test/unit/set-bio-page.test.js @@ -0,0 +1,110 @@ +/* + Unit tests for the Set Bio page controller. + + The page controller wraps the Memo set-bio behavior, exposes a remaining + byte count, and navigates to the account page on a successful broadcast. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const SetBioPage = require('../../src/services/set-bio-page') +const MemoSetBio = require('../../src/services/memo-set-bio') + +function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { + return { + walletInfo: { cashAddress: address }, + broadcasts: [], + async getUtxos () { + return [] + }, + async sendOpReturn (msg, prefix) { + this.broadcasts.push({ msg, prefix }) + return 'aa'.repeat(32) + } + } +} + +function makeMemoSetBio () { + const wallet = makeWallet() + return new MemoSetBio({ wallet }) +} + +test('remainingCount returns full byte budget for empty input', () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + + assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES) +}) + +test('remainingCount subtracts the byte length of the input', () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + page.setInput('hello') + + assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES - 5) +}) + +test('remainingCount counts multi-byte characters correctly', () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + page.setInput('é') + + assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES - 2) +}) + +test('submit navigates to the account page on success', async () => { + const navigated = [] + const page = new SetBioPage({ + memoSetBio: makeMemoSetBio(), + navigate: (path) => navigated.push(path) + }) + page.setInput('Building on BCH') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.deepEqual(navigated, [SetBioPage.ACCOUNT_PATH]) +}) + +test('submit records a validation error for empty input', async () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + page.setInput('') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'bio_validation') +}) + +test('submit records a length error for over-long input', async () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + page.setInput('a'.repeat(MemoSetBio.MAX_BIO_BYTES + 1)) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'bio_length') +}) + +test('submit surfaces a broadcast failure', async () => { + const memoSetBio = makeMemoSetBio() + memoSetBio.wallet.sendOpReturn = async () => { throw new Error('network down') } + const page = new SetBioPage({ memoSetBio, navigate: () => {} }) + page.setInput('Building on BCH') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(result.message, /network down/) +}) + +test('submit records a broadcast error when no memo set-bio handler is injected', async () => { + const page = new SetBioPage({ navigate: () => {} }) + page.setInput('Building on BCH') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(result.message, /Set bio requires a memo set-bio handler/) +}) From 9c5767d462670d9fbb897170c86348cc38ef6542 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 27 Aug 2026 08:00:58 -0700 Subject: [PATCH 5/5] Refactor set-bio to share profile-text action and page bases Deduplicate the set-bio and set-name action and page-controller layers: move byte-limit and profile-store reflection into MemoAction via config, and share a ProfileTextPage base for both page controllers. Add property tests for byte counting and the bio byte-limit boundary. By refactorer. --- psf-memo-client/src/services/account-page.js | 24 +-- psf-memo-client/src/services/memo-action.js | 36 ++++ psf-memo-client/src/services/memo-set-bio.js | 22 +-- psf-memo-client/src/services/memo-set-name.js | 22 +-- .../src/services/profile-text-page.js | 59 +++++++ psf-memo-client/src/services/set-bio-page.js | 50 ++---- psf-memo-client/src/services/set-name-page.js | 51 ++---- .../test/property/set-bio.property.test.js | 157 ++++++++++++++++++ 8 files changed, 301 insertions(+), 120 deletions(-) create mode 100644 psf-memo-client/src/services/profile-text-page.js create mode 100644 psf-memo-client/test/property/set-bio.property.test.js diff --git a/psf-memo-client/src/services/account-page.js b/psf-memo-client/src/services/account-page.js index 172ef56..4cf79dc 100644 --- a/psf-memo-client/src/services/account-page.js +++ b/psf-memo-client/src/services/account-page.js @@ -27,24 +27,24 @@ class AccountPage { return this.wallet?.walletInfo?.cashAddress || null } - // The current display name for the authenticated address. Falls back to null - // when no wallet, profile store, or stored name exists. - getName () { + // Read a profile field for the authenticated address. Falls back to null + // when no wallet, profile store, or stored field exists. + _getProfileField (method) { const address = this.getAddress() - if (!address || !this.profiles || typeof this.profiles.getName !== 'function') { + if (!address || !this.profiles || typeof this.profiles[method] !== 'function') { return null } - return this.profiles.getName(address) + return this.profiles[method](address) } - // The current bio for the authenticated address. Falls back to null when no - // wallet, profile store, or stored bio exists. + // The current display name for the authenticated address. + getName () { + return this._getProfileField('getName') + } + + // The current bio for the authenticated address. getBio () { - const address = this.getAddress() - if (!address || !this.profiles || typeof this.profiles.getBio !== 'function') { - return null - } - return this.profiles.getBio(address) + return this._getProfileField('getBio') } // Whether the account page exposes a Set Name button. diff --git a/psf-memo-client/src/services/memo-action.js b/psf-memo-client/src/services/memo-action.js index d68a6bd..c7f9f2f 100644 --- a/psf-memo-client/src/services/memo-action.js +++ b/psf-memo-client/src/services/memo-action.js @@ -7,8 +7,15 @@ lengthCode, validationCode) or as methods: isTooLong(value) - true when the value exceeds the action's limit reflect(txid, value) - record the broadcast result on the injected store + + Optional config keys enable shared defaults for a profile text action: + maxBytes - byte limit used by the default isTooLong(value) + profileMethod - injected profile store method used by the default + reflect(txid, value) */ +const { byteLength } = require('./utf8') + class MemoAction { constructor (deps = {}) { this.wallet = deps.wallet @@ -19,6 +26,35 @@ class MemoAction { this.emptyMessage = cfg.emptyMessage this.lengthCode = cfg.lengthCode this.validationCode = cfg.validationCode + this.maxBytes = cfg.maxBytes ?? null + this.profileMethod = cfg.profileMethod ?? null + // Profile text actions (config.profileMethod set) receive the injected + // profile store here so subclasses do not each re-wire it. + if (this.profileMethod) { + this.profiles = deps.profiles + } + } + + // Default over-length check driven by the config maxBytes. Subclasses that + // measure limits differently override this method. + isTooLong (value) { + if (this.maxBytes === null) { + throw new Error('isTooLong must be provided by the subclass.') + } + return byteLength(value) > this.maxBytes + } + + // Default reflect that records the value on the injected profile store + // method named by the config profileMethod. Subclasses with other stores + // override this method. + reflect (txid, value) { + if ( + this.profileMethod && + this.profiles && + typeof this.profiles[this.profileMethod] === 'function' + ) { + this.profiles[this.profileMethod](this.wallet.walletInfo.cashAddress, value) + } } // Validate a candidate value. diff --git a/psf-memo-client/src/services/memo-set-bio.js b/psf-memo-client/src/services/memo-set-bio.js index b9a8bde..198cfa1 100644 --- a/psf-memo-client/src/services/memo-set-bio.js +++ b/psf-memo-client/src/services/memo-set-bio.js @@ -17,7 +17,6 @@ */ const MemoAction = require('./memo-action') -const { byteLength } = require('./utf8') const MEMO_SET_BIO_PREFIX = '6d05' const MAX_BIO_BYTES = 217 @@ -29,17 +28,9 @@ class MemoSetBio extends MemoAction { lengthMessage: `Bio is too long. Maximum is ${MAX_BIO_BYTES} bytes.`, emptyMessage: 'Bio must not be empty.', lengthCode: 'bio_length', - validationCode: 'bio_validation' - } - - constructor (deps = {}) { - super(deps) - this.profiles = deps.profiles - } - - // A bio is over-length when it exceeds the byte limit. - isTooLong (bio) { - return byteLength(bio) > MAX_BIO_BYTES + validationCode: 'bio_validation', + maxBytes: MAX_BIO_BYTES, + profileMethod: 'setBio' } // Compose and broadcast a Memo set-bio transaction for the given bio. @@ -47,13 +38,6 @@ class MemoSetBio extends MemoAction { async setBio (bio) { return this.broadcast(bio) } - - // Record the new bio on the injected profile store when one is present. - reflect (txid, bio) { - if (this.profiles && typeof this.profiles.setBio === 'function') { - this.profiles.setBio(this.wallet.walletInfo.cashAddress, bio) - } - } } MemoSetBio.MEMO_SET_BIO_PREFIX = MEMO_SET_BIO_PREFIX diff --git a/psf-memo-client/src/services/memo-set-name.js b/psf-memo-client/src/services/memo-set-name.js index 637cc43..d6b4030 100644 --- a/psf-memo-client/src/services/memo-set-name.js +++ b/psf-memo-client/src/services/memo-set-name.js @@ -17,7 +17,6 @@ */ const MemoAction = require('./memo-action') -const { byteLength } = require('./utf8') const MEMO_SET_NAME_PREFIX = '6d01' const MAX_NAME_BYTES = 77 @@ -29,17 +28,9 @@ class MemoSetName extends MemoAction { lengthMessage: `Name is too long. Maximum is ${MAX_NAME_BYTES} bytes.`, emptyMessage: 'Name must not be empty.', lengthCode: 'name_length', - validationCode: 'name_validation' - } - - constructor (deps = {}) { - super(deps) - this.profiles = deps.profiles - } - - // A name is over-length when it exceeds the byte limit. - isTooLong (name) { - return byteLength(name) > MAX_NAME_BYTES + validationCode: 'name_validation', + maxBytes: MAX_NAME_BYTES, + profileMethod: 'setName' } // Compose and broadcast a Memo set-name transaction for the given name. @@ -47,13 +38,6 @@ class MemoSetName extends MemoAction { async setName (name) { return this.broadcast(name) } - - // Record the new name on the injected profile store when one is present. - reflect (txid, name) { - if (this.profiles && typeof this.profiles.setName === 'function') { - this.profiles.setName(this.wallet.walletInfo.cashAddress, name) - } - } } MemoSetName.MEMO_SET_NAME_PREFIX = MEMO_SET_NAME_PREFIX diff --git a/psf-memo-client/src/services/profile-text-page.js b/psf-memo-client/src/services/profile-text-page.js new file mode 100644 index 0000000..dee8195 --- /dev/null +++ b/psf-memo-client/src/services/profile-text-page.js @@ -0,0 +1,59 @@ +/* + Shared base for page controllers that set a Memo profile text field (e.g. a + display name or a bio). + + A profile text page holds the current input, counts down the remaining byte + budget, validates/broadcasts through an injected action handler, and + navigates to the account page on success. + + Subclasses supply a static config: + handlerKey - deps key holding the action handler + busyKey - instance key for the in-flight flag + actionMethod - handler method to invoke for the current input + requiresMsg - error message when no handler is injected + maxBytes - the profile text field's byte limit + validationCodes - error codes for local validation failures + + The handler and navigate concerns are injected so this module stays free of + UI/network concerns; environmentally unsuitable I/O lives behind those small + adapter boundaries. +*/ + +const PageController = require('./page-controller') +const { byteLength } = require('./utf8') + +const ACCOUNT_PATH = '/account' + +class ProfileTextPage extends PageController { + constructor (deps = {}) { + super(deps) + const cfg = this.constructor.config + this[cfg.handlerKey] = deps[cfg.handlerKey] || null + this[cfg.busyKey] = false + this.successPath = ACCOUNT_PATH + this.validationCodes = cfg.validationCodes + } + + // Bytes remaining before the profile text limit is reached. + remainingCount () { + return this.constructor.config.maxBytes - byteLength(this.input) + } + + // Set the in-flight flag. + _setBusy (value) { + this[this.constructor.config.busyKey] = value + } + + // Run the action handler for the current input. + async _perform (input) { + const cfg = this.constructor.config + if (!this[cfg.handlerKey]) { + throw new Error(cfg.requiresMsg) + } + return this[cfg.handlerKey][cfg.actionMethod](input) + } +} + +ProfileTextPage.ACCOUNT_PATH = ACCOUNT_PATH + +module.exports = ProfileTextPage diff --git a/psf-memo-client/src/services/set-bio-page.js b/psf-memo-client/src/services/set-bio-page.js index aa07c5c..784414a 100644 --- a/psf-memo-client/src/services/set-bio-page.js +++ b/psf-memo-client/src/services/set-bio-page.js @@ -3,52 +3,32 @@ byte counter that counts down from the bio limit. This is the testable controller behind the React "Set Bio" page. It wraps - the Memo set-bio behavior (src/services/memo-set-bio.js) and adds page-level - concerns: holding the current input, computing the remaining byte count, - surfacing validation/length errors, and navigating to the account page after - a successful broadcast. + the Memo set-bio behavior (src/services/memo-set-bio.js) through the shared + ProfileTextPage base and adds the page-level config: the injected handler + key, the in-flight flag, the byte limit, and the local validation codes. The memoSetBio and navigate concerns are injected so this module stays free - of UI/network concerns; environmentally unsuitable I/O lives behind those small - adapter boundaries. + of UI/network concerns; environmentally unsuitable I/O lives behind those + small adapter boundaries. */ -const PageController = require('./page-controller') +const ProfileTextPage = require('./profile-text-page') const MemoSetBio = require('./memo-set-bio') -const { byteLength } = require('./utf8') const SET_BIO_PATH = '/memo/set-bio' -const ACCOUNT_PATH = '/account' -class SetBioPage extends PageController { - constructor (deps = {}) { - super(deps) - this.memoSetBio = deps.memoSetBio || null - this.settingBio = false - this.successPath = ACCOUNT_PATH - this.validationCodes = ['bio_validation', 'bio_length'] - } - - // Bytes remaining before the bio limit is reached. - remainingCount () { - return MemoSetBio.MAX_BIO_BYTES - byteLength(this.input) - } - - // Set the in-flight setting-bio flag. - _setBusy (value) { - this.settingBio = value - } - - // Run the memo set-bio action for the current input. - async _perform (input) { - if (!this.memoSetBio) { - throw new Error('Set bio requires a memo set-bio handler.') - } - return this.memoSetBio.setBio(input) +class SetBioPage extends ProfileTextPage { + static config = { + handlerKey: 'memoSetBio', + busyKey: 'settingBio', + actionMethod: 'setBio', + requiresMsg: 'Set bio requires a memo set-bio handler.', + maxBytes: MemoSetBio.MAX_BIO_BYTES, + validationCodes: ['bio_validation', 'bio_length'] } } SetBioPage.SET_BIO_PATH = SET_BIO_PATH -SetBioPage.ACCOUNT_PATH = ACCOUNT_PATH +SetBioPage.ACCOUNT_PATH = ProfileTextPage.ACCOUNT_PATH module.exports = SetBioPage diff --git a/psf-memo-client/src/services/set-name-page.js b/psf-memo-client/src/services/set-name-page.js index 874d808..ce62275 100644 --- a/psf-memo-client/src/services/set-name-page.js +++ b/psf-memo-client/src/services/set-name-page.js @@ -3,53 +3,34 @@ byte counter that counts down from the name limit. This is the testable controller behind the React "Set Name" page. It wraps - the Memo set-name behavior (src/services/memo-set-name.js) and adds page-level - concerns: holding the current input, computing the remaining byte count, - surfacing validation/length errors, and navigating to the account page after - a successful broadcast. + the Memo set-name behavior (src/services/memo-set-name.js) through the + shared ProfileTextPage base and adds the page-level config: the injected + handler key, the in-flight flag, the byte limit, and the local validation + codes. The memoSetName and navigate concerns are injected so this module stays free - of UI/network concerns; environmentally unsuitable I/O lives behind those small - adapter boundaries. + of UI/network concerns; environmentally unsuitable I/O lives behind those + small adapter boundaries. */ -const PageController = require('./page-controller') +const ProfileTextPage = require('./profile-text-page') const MemoSetName = require('./memo-set-name') -const { byteLength } = require('./utf8') const SET_NAME_PATH = '/memo/set-name' -const ACCOUNT_PATH = '/account' -class SetNamePage extends PageController { - constructor (deps = {}) { - super(deps) - this.memoSetName = deps.memoSetName || null - this.settingName = false - this.successPath = ACCOUNT_PATH - this.validationCodes = ['name_validation', 'name_length'] - } - - // Bytes remaining before the name limit is reached. - remainingCount () { - return MemoSetName.MAX_NAME_BYTES - byteLength(this.input) - } - - // Set the in-flight setting-name flag. - _setBusy (value) { - this.settingName = value - } - - // Run the memo set-name action for the current input. - async _perform (input) { - if (!this.memoSetName) { - throw new Error('Set name requires a memo set-name handler.') - } - return this.memoSetName.setName(input) +class SetNamePage extends ProfileTextPage { + static config = { + handlerKey: 'memoSetName', + busyKey: 'settingName', + actionMethod: 'setName', + requiresMsg: 'Set name requires a memo set-name handler.', + maxBytes: MemoSetName.MAX_NAME_BYTES, + validationCodes: ['name_validation', 'name_length'] } } SetNamePage.SET_NAME_PATH = SET_NAME_PATH -SetNamePage.ACCOUNT_PATH = ACCOUNT_PATH +SetNamePage.ACCOUNT_PATH = ProfileTextPage.ACCOUNT_PATH module.exports = SetNamePage diff --git a/psf-memo-client/test/property/set-bio.property.test.js b/psf-memo-client/test/property/set-bio.property.test.js new file mode 100644 index 0000000..e4647e4 --- /dev/null +++ b/psf-memo-client/test/property/set-bio.property.test.js @@ -0,0 +1,157 @@ +/* + Property tests for the Memo set-bio / profile-text behavior. + + The unit tests probe byte counting and the byte limit at a few fixed inputs. + These properties cover broad input ranges so the invariants hold everywhere: + + - round trip: byteLength(s) equals TextEncoder bytes, and decoding the + encoded form restores the original string. + - ordering: byteLength never reports fewer bytes than characters. + - conservation / boundary: a bio within the byte limit broadcasts and is + preserved exactly; a bio over the limit is rejected with bio_length and + never broadcast. + - byte budget: the Set Bio page's remaining count equals the byte budget + minus the input's byte length for any input. +*/ + +'use strict' + +const test = require('node:test') +const { seededRandom, forAll, intGen } = require('./harness') +const MemoSetBio = require('../../src/services/memo-set-bio') +const SetBioPage = require('../../src/services/set-bio-page') +const { byteLength } = require('../../src/services/utf8') + +const rng = seededRandom(20260828) + +// A pool of code points mixing ASCII and multi-byte UTF-8 so a string's byte +// length differs from its character count. Held as separate strings so no +// surrogate pair is ever split. +const POOL = ['a', 'b', 'Z', ' ', '9', 'é', 'ñ', '你', '😀'] + +// Build a random string of at most maxChars characters. +function randomString (maxChars) { + const len = intGen(rng, 0, maxChars)() + let out = '' + for (let i = 0; i < len; i++) { + out += POOL[Math.floor(rng() * POOL.length)] + } + return out +} + +// Build a random string whose byte length is at or under the bio limit. +function inLimitBio () { + let s = randomString(intGen(rng, 0, 200)()) + while (byteLength(s) > MemoSetBio.MAX_BIO_BYTES) { + s = randomString(intGen(rng, 0, 100)()) + } + return s +} + +// Build a random string guaranteed to exceed the bio byte limit. +function overLimitBio () { + let s = randomString(intGen(rng, 0, 300)()) + while (byteLength(s) <= MemoSetBio.MAX_BIO_BYTES) { + s += '😀'.repeat(5) + } + return s +} + +function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { + return { + walletInfo: { cashAddress: address }, + broadcasts: [], + async getUtxos () { + return [] + }, + async sendOpReturn (msg, prefix) { + this.broadcasts.push({ msg, prefix }) + return 'aa'.repeat(32) + } + } +} + +function makeProfiles () { + const bios = {} + return { + setBio: (addr, bio) => { bios[addr] = bio }, + getBio: (addr) => bios[addr] || null + } +} + +test('byteLength round-trips through TextEncoder and TextDecoder', async () => { + await forAll( + (i) => randomString(intGen(rng, 0, 60)()), + (s) => { + const bytes = new TextEncoder().encode(s) + return bytes.length === byteLength(s) && + new TextDecoder().decode(bytes) === s + }, + { label: 'utf8 byte-length round trip' } + ) +}) + +test('byteLength never reports fewer bytes than characters', async () => { + await forAll( + (i) => randomString(intGen(rng, 0, 60)()), + (s) => byteLength(s) >= s.length, + { label: 'utf8 bytes >= chars' } + ) +}) + +test('setBio broadcasts and preserves any bio within the byte limit', async () => { + await forAll( + (i) => inLimitBio(), + async (bio) => { + // An empty/whitespace bio is a validation rejection, not a length case, + // so it is out of scope for this broadcast property. + if (bio.trim().length === 0) return true + + const wallet = makeWallet() + const profiles = makeProfiles() + const memoSetBio = new MemoSetBio({ wallet, profiles }) + + try { + await memoSetBio.setBio(bio) + } catch (err) { + return false + } + + return wallet.broadcasts.length === 1 && + wallet.broadcasts[0].msg === bio && + wallet.broadcasts[0].prefix === MemoSetBio.MEMO_SET_BIO_PREFIX && + profiles.getBio(wallet.walletInfo.cashAddress) === bio + }, + { label: 'set-bio broadcasts and preserves an in-limit bio' } + ) +}) + +test('setBio rejects any bio over the byte limit without broadcasting', async () => { + await forAll( + (i) => overLimitBio(), + async (bio) => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + try { + await memoSetBio.setBio(bio) + return false // an over-limit bio must be rejected + } catch (err) { + return err.code === 'bio_length' && wallet.broadcasts.length === 0 + } + }, + { label: 'set-bio rejects an over-limit bio without broadcasting' } + ) +}) + +test('the Set Bio page remaining count conserves the byte budget', async () => { + await forAll( + (i) => randomString(intGen(rng, 0, 200)()), + (bio) => { + const page = new SetBioPage({ navigate: () => {} }) + page.setInput(bio) + return page.remainingCount() === MemoSetBio.MAX_BIO_BYTES - byteLength(bio) + }, + { label: 'set-bio remaining byte count is conserved' } + ) +})