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' }
+ )
+})