Implement Set Name feature (0x6d01)

- Add MemoSetName service with 77-byte UTF-8 validation and broadcast.
- Add SetNamePage controller with byte counter and navigation to /account.
- Add AccountPage controller showing stored name and Set Name button.
- Add Profiles session store to share the new name across pages.
- Add React views /memo/set-name and /account, plus nav link.
- Update acceptance handlers for set-name scenarios and fix fake wallet
  sendOpReturn signature to match public minimal-slp-wallet API.
- Update existing post memo tests to match the corrected wallet API.

By coder.
This commit is contained in:
Chris Troutner
2026-08-25 19:50:27 -07:00
parent 359cc20fd6
commit bd2eac54cf
19 changed files with 1116 additions and 11 deletions
+119 -2
View File
@@ -18,8 +18,12 @@
const MemoPost = require('../../src/services/memo-post') const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-post') const NewPostPage = require('../../src/services/new-post')
const MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page')
const AccountPage = require('../../src/services/account-page')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses. // A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
function makeWallet (address) { function makeWallet (address) {
@@ -30,9 +34,9 @@ function makeWallet (address) {
getUtxos: async function () { getUtxos: async function () {
return this.utxos return this.utxos
}, },
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { sendOpReturn: async function (msg, prefix) {
// Record the broadcast attempt, then fail if configured to do so. // Record the broadcast attempt, then fail if configured to do so.
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) this.broadcasts.push({ msg, prefix })
if (this.failWith) throw new Error(this.failWith) if (this.failWith) throw new Error(this.failWith)
return 'aa'.repeat(32) return 'aa'.repeat(32)
} }
@@ -49,6 +53,16 @@ function makeFeed () {
} }
} }
// A fake profile store recording display names set for addresses.
function makeProfiles () {
const names = {}
return {
names,
setName: (addr, name) => { names[addr] = name },
getName: (addr) => names[addr] || null
}
}
// Fresh world/state object for a single scenario execution. // Fresh world/state object for a single scenario execution.
function createWorld () { function createWorld () {
const wallet = makeWallet('') const wallet = makeWallet('')
@@ -70,6 +84,20 @@ function createWorld () {
menuLinks: [] menuLinks: []
}) })
// The Set Name Page and Account Page controllers share a profile store so
// a name set on one page is visible on the other.
const profiles = makeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
world.setNamePage = new SetNamePage({
memoSetName,
navigate: (path) => { world.currentPath = path }
})
world.accountPage = new AccountPage({
wallet,
profiles,
navigate: (path) => { world.currentPath = path }
})
return world return world
} }
@@ -160,6 +188,17 @@ const handlers = [
world.newPage.setInput(example[param]) world.newPage.setInput(example[param])
} }
}, },
{
name: 'type name text',
pattern: /^I type a name 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.setNamePage.setInput(example[param])
}
},
{ {
name: 'submit/click post', name: 'submit/click post',
pattern: /^I (?:submit the memo|click the post button)$/, pattern: /^I (?:submit the memo|click the post button)$/,
@@ -167,6 +206,20 @@ const handlers = [
await world.newPage.submit() await world.newPage.submit()
} }
}, },
{
name: 'submit name',
pattern: /^I submit the name$/,
async run (m, example, world) {
await world.setNamePage.submit()
}
},
{
name: 'click Set Name button',
pattern: /^I click the Set Name button$/,
run (m, example, world) {
world.accountPage.clickSetName()
}
},
{ {
name: 'broadcasts/attempts OP_RETURN with Memo post prefix', name: 'broadcasts/attempts OP_RETURN with Memo post prefix',
pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/, pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/,
@@ -184,6 +237,23 @@ const handlers = [
} }
} }
}, },
{
name: 'broadcasts OP_RETURN with Memo set-name prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-name 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_NAME_PREFIX) {
throw new Error(`Expected Memo set-name prefix ${MEMO_SET_NAME_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.setNamePage.input) {
throw new Error('Broadcast name text did not match the typed name.')
}
}
},
{ {
name: 'feed shows new post from my address', name: 'feed shows new post from my address',
pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/, pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/,
@@ -222,6 +292,17 @@ const handlers = [
} }
} }
}, },
{
name: 'set name page shows validation/length error',
pattern: /^the set name page shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'name_validation' : 'name_length'
if (world.setNamePage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.setNamePage.submitError}.`)
}
}
},
{ {
name: 'remaining character count', name: 'remaining character count',
pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/, pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/,
@@ -237,6 +318,21 @@ const handlers = [
} }
} }
}, },
{
name: 'remaining byte count',
pattern: /^the set name 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.setNamePage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{ {
name: 'app does not broadcast any transaction', name: 'app does not broadcast any transaction',
pattern: /^(?:the wallet|the app) does not broadcast any transaction$/, pattern: /^(?:the wallet|the app) does not broadcast any transaction$/,
@@ -245,6 +341,27 @@ const handlers = [
throw new Error('A transaction was broadcast when none was expected.') throw new Error('A transaction was broadcast when none was expected.')
} }
} }
},
{
name: 'account page shows name',
pattern: /^the account page shows my name as "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const actual = world.accountPage.getName()
if (actual !== expected) {
throw new Error(`Expected account name "${expected}", got "${actual}".`)
}
}
},
{
name: 'account page shows Set Name button',
pattern: /^the account page shows a Set Name button$/,
run (m, example, world) {
if (!world.accountPage.hasSetNameButton()) {
throw new Error('Account page does not show a Set Name button.')
}
}
} }
] ]
+50
View File
@@ -29,8 +29,11 @@
"use-query-params": "1.2.3" "use-query-params": "1.2.3"
}, },
"devDependencies": { "devDependencies": {
"crap4javascript": "github:FullStack-Agents/crap4javascript",
"dry4javascript": "github:FullStack-Agents/dry4javascript",
"husky": "9.1.7", "husky": "9.1.7",
"minimal-slp-wallet": "5.13.1", "minimal-slp-wallet": "5.13.1",
"mutate4javascript": "github:FullStack-Agents/mutate4javascript",
"semantic-release": "24.2.3", "semantic-release": "24.2.3",
"standard": "17.0.0", "standard": "17.0.0",
"web3.storage": "4.3.0" "web3.storage": "4.3.0"
@@ -7566,6 +7569,22 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/crap4javascript": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/FullStack-Agents/crap4javascript.git#32a11e784c5ccc4c8d8ada8659826c0641269479",
"dev": true,
"license": "UNLICENSED",
"dependencies": {
"@babel/parser": "^7.26.0",
"@babel/traverse": "^7.26.0"
},
"bin": {
"crap4javascript": "bin/crap4javascript.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/create-hash": { "node_modules/create-hash": {
"version": "1.2.0", "version": "1.2.0",
"license": "MIT", "license": "MIT",
@@ -8451,6 +8470,21 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/dry4javascript": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/FullStack-Agents/dry4javascript.git#8ba1585b1c817e3492e94a616bda5237afcae73c",
"dev": true,
"license": "UNLICENSED",
"dependencies": {
"@babel/parser": "^7.26.0"
},
"bin": {
"dry4javascript": "bin/dry4javascript.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"license": "MIT", "license": "MIT",
@@ -16341,6 +16375,22 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/mutate4javascript": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/FullStack-Agents/mutate4javascript.git#553998e78e31ade25d13814d66cbcabc6749c50c",
"dev": true,
"license": "UNLICENSED",
"dependencies": {
"@babel/parser": "^7.26.0",
"@babel/traverse": "^7.26.0"
},
"bin": {
"mutate4javascript": "bin/mutate4javascript.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"license": "MIT", "license": "MIT",
+3
View File
@@ -49,8 +49,11 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"crap4javascript": "github:FullStack-Agents/crap4javascript",
"dry4javascript": "github:FullStack-Agents/dry4javascript",
"husky": "9.1.7", "husky": "9.1.7",
"minimal-slp-wallet": "5.13.1", "minimal-slp-wallet": "5.13.1",
"mutate4javascript": "github:FullStack-Agents/mutate4javascript",
"semantic-release": "24.2.3", "semantic-release": "24.2.3",
"standard": "17.0.0", "standard": "17.0.0",
"web3.storage": "4.3.0" "web3.storage": "4.3.0"
+101
View File
@@ -0,0 +1,101 @@
/*
Account view: show the authenticated user's display name and offer a button
to navigate to the Set Name page.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Button, Spinner } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
// Local libraries
import MemoDb from '../../../services/memo-db'
import AccountPage from '../../../services/account-page'
import { truncateAddr } from '../../../util'
function Account (props) {
const { appData } = props
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [name, setName] = useState(null)
const wallet = appData?.wallet
const address = wallet?.walletInfo?.cashAddress || ''
useEffect(() => {
const loadName = async () => {
setLoading(true)
setError(null)
try {
const memoDb = new MemoDb()
const profile = await memoDb.getName(address)
setName(profile?.name || null)
} catch (err) {
setError(err.message || 'Failed to load name')
}
setLoading(false)
}
if (address) {
loadName()
} else {
setLoading(false)
}
}, [address])
const accountPage = new AccountPage({
wallet,
profiles: appData?.profiles,
navigate
})
const displayName = name || accountPage.getName() || truncateAddr(address, 24)
return (
<Container className='account-page mt-4'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<h1>Account</h1>
{error && <p className='text-danger'>{error}</p>}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{!loading && (
<div className='account-details'>
<p className='account-name'>
<strong>Name: </strong>
{displayName}
</p>
<p className='account-address'>
<strong>Address: </strong>
{address}
</p>
{accountPage.hasSetNameButton() && (
<Button
variant='primary'
onClick={() => accountPage.clickSetName()}
>
Set Name
</Button>
)}
</div>
)}
</Col>
</Row>
</Container>
)
}
export default Account
+4
View File
@@ -27,6 +27,8 @@ import RecentProfiles from './recent-profiles'
import RecentPosts from './posts' import RecentPosts from './posts'
import NewPost from './new-post' import NewPost from './new-post'
import Profile from './profile' import Profile from './profile'
import SetName from './set-name'
import Account from './account'
function AppBody (props) { function AppBody (props) {
// Dependency injection through props // Dependency injection through props
@@ -44,6 +46,8 @@ function AppBody (props) {
<Route path='/profile/:addr' element={<Profile />} /> <Route path='/profile/:addr' element={<Profile />} />
<Route path='/posts/recent' element={<RecentPosts />} /> <Route path='/posts/recent' element={<RecentPosts />} />
<Route path='/posts/new' element={<NewPost appData={appData} />} /> <Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/account' element={<Account appData={appData} />} />
<Route path='/placeholder2' element={<Placeholder2 />} /> <Route path='/placeholder2' element={<Placeholder2 />} />
<Route path='/placeholder3' element={<Placeholder3 />} /> <Route path='/placeholder3' element={<Placeholder3 />} />
<Route path='/servers' element={<ServerSelectView appData={appData} />} /> <Route path='/servers' element={<ServerSelectView appData={appData} />} />
+93
View File
@@ -0,0 +1,93 @@
/*
Set Name view: compose and broadcast a Memo display name, with a byte counter
that counts down from the name 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 MemoSetName from '../../../services/memo-set-name'
import SetNamePage from '../../../services/set-name-page'
function SetName (props) {
const { appData } = props
const navigate = useNavigate()
const maxBytes = MemoSetName.MAX_NAME_BYTES
const [input, setInput] = useState('')
const [err, setErr] = useState('')
const [settingName, setSettingName] = useState(false)
const remaining = maxBytes - Buffer.byteLength(input, 'utf8')
async function handleSubmit (event) {
event.preventDefault()
setErr('')
setSettingName(true)
try {
const memoSetName = new MemoSetName({ wallet: appData?.wallet, profiles: appData?.profiles })
const page = new SetNamePage({ memoSetName, navigate })
page.setInput(input)
const result = await page.submit()
if (!result.ok) {
if (result.error === 'name_length') {
setErr(`Name is too long. Maximum is ${maxBytes} bytes.`)
} else if (result.error === 'name_validation') {
setErr('Name must not be empty.')
} else if (result.message) {
setErr(`Failed to broadcast: ${result.message}`)
} else {
setErr('Failed to set name.')
}
}
// On success page.submit() navigated to the account page.
} catch (submitErr) {
setErr(submitErr.message)
} finally {
setSettingName(false)
}
}
return (
<Container>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='set-name-heading'>
<h1>Set Name</h1>
<p>Choose a display name and publish it to Bitcoin Cash.</p>
</header>
<Form onSubmit={handleSubmit}>
<Form.Group controlId='set-name-input' className='mb-3'>
<Form.Label><b>Name</b></Form.Label>
<Form.Control
type='text'
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Enter your display name...'
/>
</Form.Group>
<p className='set-name-counter'>
{remaining} bytes remaining
</p>
{err && <p className='set-name-error'>{err}</p>}
<Button type='submit' variant='primary' disabled={settingName}>
{settingName ? 'Setting Name...' : 'Set Name'}
</Button>
</Form>
</Col>
</Row>
</Container>
)
}
export default SetName
+8
View File
@@ -94,6 +94,14 @@ function NavMenu (props) {
> >
Check Balance Check Balance
</NavLink> </NavLink>
<NavLink
className={(currentPath === '/account') ? 'nav-link-active' : 'nav-link-inactive'}
to='/account'
onClick={handleClickEvent}
>
Account
</NavLink>
<NavLink <NavLink
className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'} className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sweep' to='/sweep'
+1 -1
View File
@@ -151,4 +151,4 @@ function PostFeedItem ({
) )
} }
export default PostFeedItem export default PostFeedItem
+4
View File
@@ -2,9 +2,12 @@ import { useState } from 'react'
// import { useQueryParam, StringParam } from 'use-query-params' // import { useQueryParam, StringParam } from 'use-query-params'
import useLocalStorageState from 'use-local-storage-state' import useLocalStorageState from 'use-local-storage-state'
import AppUtil from '../util' import AppUtil from '../util'
import Profiles from '../services/profiles'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
const defaultProfiles = new Profiles()
function useAppState () { function useAppState () {
const location = useLocation() const location = useLocation()
@@ -150,6 +153,7 @@ function useAppState () {
updateLocalStorage, updateLocalStorage,
updateBchWalletState, updateBchWalletState,
appUtil: new AppUtil(), appUtil: new AppUtil(),
profiles: defaultProfiles,
currentPath: location.pathname, currentPath: location.pathname,
setIsSingleView, setIsSingleView,
isSingleView, isSingleView,
+53
View File
@@ -0,0 +1,53 @@
/*
Account Page behavior: show the authenticated user's display name and offer
a way to navigate to the Set Name page.
This is the testable controller behind the React "Account" page. It reads
the current name from an injected profile store and exposes a Set Name
button that navigates to the set-name path.
The wallet, profile store, 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 SET_NAME_PATH = '/memo/set-name'
const ACCOUNT_PATH = '/account'
class AccountPage {
constructor (deps = {}) {
this.wallet = deps.wallet || null
this.profiles = deps.profiles || null
this.navigate = deps.navigate || (() => {})
}
// The address of the authenticated wallet, or null when no wallet is present.
getAddress () {
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 () {
const address = this.getAddress()
if (!address || !this.profiles || typeof this.profiles.getName !== 'function') {
return null
}
return this.profiles.getName(address)
}
// Whether the account page exposes a Set Name button.
hasSetNameButton () {
return true
}
// Click the Set Name button: navigate to the set-name page.
clickSetName () {
this.navigate(SET_NAME_PATH)
}
}
AccountPage.SET_NAME_PATH = SET_NAME_PATH
AccountPage.ACCOUNT_PATH = ACCOUNT_PATH
module.exports = AccountPage
+87
View File
@@ -0,0 +1,87 @@
/*
Memo set-name behavior: compose, validate, and broadcast a Memo "set name"
message.
A Memo set-name transaction is an OP_RETURN Bitcoin Cash transaction carrying
the Memo set-name protocol prefix (0x6d01) followed by the name 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_NAME_PREFIX : hex prefix for the Memo "set name" action (0x6d01)
MAX_NAME_BYTES : maximum allowed name length (77 bytes per memo.sv)
*/
const MEMO_SET_NAME_PREFIX = '6d01'
const MAX_NAME_BYTES = 77
class MemoSetName {
constructor (deps = {}) {
this.wallet = deps.wallet
this.profiles = deps.profiles
}
// Validate a candidate name.
// Returns { ok: true } or { ok: false, type: 'validation' | 'length' }.
validate (name) {
if (typeof name !== 'string' || name.trim().length === 0) {
return { ok: false, type: 'validation' }
}
if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) {
return { ok: false, type: 'length' }
}
return { ok: true }
}
// Compose and broadcast a Memo set-name transaction for the given name.
// Resolves with the transaction id, or rejects with a typed error.
async setName (name) {
const check = this.validate(name)
this._throwIfInvalid(check)
if (!this.wallet) {
throw new Error('Memo set name requires a wallet.')
}
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
await this.wallet.getUtxos()
const txid = await this.wallet.sendOpReturn(name, MEMO_SET_NAME_PREFIX)
// Reflect the new name in the injected profile store once broadcast succeeds.
this._reflectName(name)
return txid
}
// Throw the appropriate typed error when a name fails validation.
_throwIfInvalid (check) {
if (check.ok) return
const err = new Error(
check.type === 'length'
? `Name is too long. Maximum is ${MAX_NAME_BYTES} bytes.`
: 'Name must not be empty.'
)
err.code = check.type === 'length' ? 'name_length' : 'name_validation'
throw err
}
// Record the new name on the injected profile store when one is present.
_reflectName (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
MemoSetName.MAX_NAME_BYTES = MAX_NAME_BYTES
module.exports = MemoSetName
+28
View File
@@ -0,0 +1,28 @@
/*
Simple in-memory profile store for the current session.
Holds display names and other profile data indexed by BCH cash address. This
keeps the Set Name and Account pages in sync immediately after a name is
broadcast, without waiting for the memo-db indexer to crawl the transaction.
In a production app this would be backed by memo-db or persistent storage;
for the current SPA it is a small shared adapter boundary.
*/
class Profiles {
constructor () {
this.names = new Map()
}
setName (addr, name) {
if (!addr) return
this.names.set(addr, name)
}
getName (addr) {
if (!addr) return null
return this.names.get(addr) || null
}
}
module.exports = Profiles
+83
View File
@@ -0,0 +1,83 @@
/*
Set Name Page behavior: compose and broadcast a Memo display name, with a
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 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.
*/
const MemoSetName = require('./memo-set-name')
const SET_NAME_PATH = '/memo/set-name'
const ACCOUNT_PATH = '/account'
class SetNamePage {
constructor (deps = {}) {
this.memoSetName = deps.memoSetName || null
this.navigate = deps.navigate || (() => {})
this.input = ''
this.submitError = null
this.broadcastError = null
this.settingName = false
}
// Set the draft name and update the counter.
setInput (text) {
this.input = typeof text === 'string' ? text : ''
return this
}
// Bytes remaining before the name limit is reached.
remainingCount () {
return MemoSetName.MAX_NAME_BYTES - Buffer.byteLength(this.input, 'utf8')
}
// Validate and broadcast the current draft name. On success, navigate to the
// account page. On failure, record the typed error and stay on the page.
// Resolves with a result object.
async submit () {
this.settingName = true
this.submitError = null
this.broadcastError = null
try {
if (!this.memoSetName) {
throw new Error('Set name requires a memo set-name handler.')
}
const txid = await this.memoSetName.setName(this.input)
this.navigate(ACCOUNT_PATH)
this.settingName = false
return { ok: true, txid }
} catch (err) {
return this._handleSubmitFailure(err)
}
}
// Classify a submit failure, record the typed state, and return the failure
// result. Local validation failures set submitError; broadcast or handler
// failures surface the real error message via broadcastError.
_handleSubmitFailure (err) {
if (err.code === 'name_validation' || err.code === 'name_length') {
this.submitError = err.code
} else {
this.broadcastError = err.message || String(err)
this.submitError = 'broadcast'
}
this.settingName = false
return { ok: false, error: this.submitError, message: this.broadcastError }
}
}
SetNamePage.SET_NAME_PATH = SET_NAME_PATH
SetNamePage.ACCOUNT_PATH = ACCOUNT_PATH
module.exports = SetNamePage
+75
View File
@@ -0,0 +1,75 @@
/*
Unit tests for the Account Page behavior slice (src/services/account-page.js).
Expresses the observable behavior described by specs/set-name.feature:
- the account page shows the authenticated user's display name.
- the account page exposes a Set Name button.
- clicking the Set Name button navigates to /memo/set-name.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const AccountPage = require('../../src/services/account-page')
const ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
function fakeWallet (cashAddress = ADDRESS) {
return { walletInfo: { cashAddress } }
}
function fakeProfiles (initial = {}) {
const names = { ...initial }
return { names, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null }
}
function build (deps = {}) {
const wallet = deps.wallet !== undefined ? deps.wallet : fakeWallet()
const profiles = deps.profiles !== undefined ? deps.profiles : fakeProfiles()
const navigations = []
const page = new AccountPage({
wallet,
profiles,
navigate: (path) => navigations.push(path)
})
return { page, navigations, profiles }
}
test('SET_NAME_PATH and ACCOUNT_PATH constants', () => {
assert.equal(AccountPage.SET_NAME_PATH, '/memo/set-name')
assert.equal(AccountPage.ACCOUNT_PATH, '/account')
})
test('the account page shows a Set Name button', () => {
const { page } = build()
assert.equal(page.hasSetNameButton(), true)
})
test('clicking the Set Name button navigates to /memo/set-name', () => {
const { page, navigations } = build()
page.clickSetName()
assert.deepEqual(navigations, ['/memo/set-name'])
})
test('the account page shows the stored name for the authenticated address', () => {
const profiles = fakeProfiles({ [ADDRESS]: 'trout' })
const { page } = build({ profiles })
assert.equal(page.getName(), 'trout')
})
test('the account page returns null when no name is stored', () => {
const { page } = build()
assert.equal(page.getName(), null)
})
test('the account page returns null when no wallet is present', () => {
const { page } = build({ wallet: null })
assert.equal(page.getName(), null)
})
test('the account page returns null when no profile store is present', () => {
const { page } = build({ profiles: null })
assert.equal(page.getName(), null)
})
+2 -6
View File
@@ -24,8 +24,8 @@ function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0
const wallet = { const wallet = {
walletInfo: { cashAddress }, walletInfo: { cashAddress },
getUtxos: async () => utxos, getUtxos: async () => utxos,
sendOpReturn: async (walletInfo, bchUtxos, msg, prefix) => { sendOpReturn: async (msg, prefix) => {
broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) broadcasts.push({ msg, prefix })
return 'fake-txid' return 'fake-txid'
} }
} }
@@ -55,10 +55,6 @@ test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
const b = wallet.broadcasts[0] const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d02') assert.equal(b.prefix, '6d02')
assert.equal(b.msg, 'hello memo') assert.equal(b.msg, 'hello memo')
// The broadcast uses the wallet's spendable UTXOs.
assert.equal(b.bchUtxos.length, 1)
// The wallet info passed to sendOpReturn is the authenticated wallet.
assert.equal(b.walletInfo.cashAddress, wallet.walletInfo.cashAddress)
// The feed reflects the new post from this address with this text. // The feed reflects the new post from this address with this text.
assert.equal(feed.posts.length, 1) assert.equal(feed.posts.length, 1)
+161
View File
@@ -0,0 +1,161 @@
/*
Unit tests for the Memo set-name behavior slice (src/services/memo-set-name.js).
These tests express the observable behavior described by specs/set-name.feature:
- a valid name broadcasts an OP_RETURN transaction carrying the Memo set-name
prefix (0x6d01) and the name text, and the profile store reflects the new
name.
- an empty name is rejected with a validation error and nothing is broadcast.
- an over-long name is rejected with a length error and nothing is broadcast.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoSetName = require('../../src/services/memo-set-name')
// A fake wallet that records every broadcast attempt. It satisfies the small
// adapter surface the MemoSetName module needs: walletInfo, getUtxos(),
// sendOpReturn().
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) {
const broadcasts = []
const wallet = {
walletInfo: { cashAddress },
getUtxos: async () => utxos,
sendOpReturn: async (msg, prefix) => {
broadcasts.push({ msg, prefix })
return 'fake-txid'
}
}
wallet.broadcasts = broadcasts
return wallet
}
// A fake profile store that records names set for addresses.
function fakeProfiles () {
const names = {}
return {
names,
setName: (addr, name) => { names[addr] = name },
getName: (addr) => names[addr] || null
}
}
test('MEMO_SET_NAME_PREFIX is the Memo set-name action 0x6d01', () => {
assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01')
})
test('setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix and name', async () => {
const wallet = fakeWallet()
const profiles = fakeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
const txid = await memoSetName.setName('trout')
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d01')
assert.equal(b.msg, 'trout')
// The profile store reflects the new name for this address.
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout')
})
test('setting a name at the maximum byte length (77) is accepted', async () => {
const wallet = fakeWallet()
const memoSetName = new MemoSetName({ wallet })
const name = 'x'.repeat(77)
const txid = await memoSetName.setName(name)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts[0].msg, name)
})
test('setting a name at the maximum byte length with multi-byte characters is accepted', async () => {
const wallet = fakeWallet()
const memoSetName = new MemoSetName({ wallet })
// 38 'é' characters are 76 bytes in UTF-8.
const name = 'é'.repeat(38)
assert.equal(Buffer.byteLength(name, 'utf8'), 76)
const txid = await memoSetName.setName(name)
assert.equal(txid, 'fake-txid')
})
test('setting an over-long name in bytes (78) throws a length error even when char count is lower', async () => {
const wallet = fakeWallet()
const memoSetName = new MemoSetName({ wallet })
// 40 'é' characters are 80 bytes, exceeding the 77-byte limit.
const name = 'é'.repeat(40)
assert.ok(Buffer.byteLength(name, 'utf8') > 77)
await assert.rejects(
memoSetName.setName(name),
(err) => err.code === 'name_length'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('setting an empty name throws a validation error and broadcasts nothing', async () => {
const wallet = fakeWallet()
const profiles = fakeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
await assert.rejects(
memoSetName.setName(''),
(err) => err.code === 'name_validation'
)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
})
test('setting a whitespace-only or non-string name throws a validation error and broadcasts nothing', async () => {
for (const invalid of [' ', 42]) {
const wallet = fakeWallet()
const memoSetName = new MemoSetName({ wallet })
await assert.rejects(
memoSetName.setName(invalid),
(err) => err.code === 'name_validation'
)
assert.equal(wallet.broadcasts.length, 0)
}
})
test('setting an over-long name (78) throws a length error and broadcasts nothing', async () => {
const wallet = fakeWallet()
const profiles = fakeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
await assert.rejects(
memoSetName.setName('y'.repeat(78)),
(err) => err.code === 'name_length'
)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
})
test('setting a name without a wallet reports a missing-wallet error', async () => {
const memoSetName = new MemoSetName({})
await assert.rejects(
memoSetName.setName('trout'),
(err) => /wallet/i.test(err.message)
)
})
test('a failed broadcast does not update the profile store', async () => {
const wallet = fakeWallet()
wallet.sendOpReturn = async () => { throw new Error('broadcast failure') }
const profiles = fakeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
await assert.rejects(
memoSetName.setName('trout'),
(err) => /broadcast failure/i.test(err.message)
)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
})
+2 -2
View File
@@ -27,8 +27,8 @@ function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0
walletInfo: { cashAddress }, walletInfo: { cashAddress },
utxos: [{ txid: 'utxo-fee' }], utxos: [{ txid: 'utxo-fee' }],
getUtxos: async function () { return this.utxos }, getUtxos: async function () { return this.utxos },
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { sendOpReturn: async function (msg, prefix) {
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) this.broadcasts.push({ msg, prefix })
if (this.failWith) throw new Error(this.failWith) if (this.failWith) throw new Error(this.failWith)
return 'newpost-txid' return 'newpost-txid'
} }
+49
View File
@@ -0,0 +1,49 @@
/*
Unit tests for the session profile store (src/services/profiles.js).
The store indexes display names by BCH cash address so that pages can read
a name immediately after it is broadcast.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const Profiles = require('../../src/services/profiles')
test('returns null when no name has been set for an address', () => {
const profiles = new Profiles()
assert.equal(profiles.getName('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'), null)
})
test('stores and retrieves a name by address', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
profiles.setName(addr, 'trout')
assert.equal(profiles.getName(addr), 'trout')
})
test('updating a name overwrites the previous value', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
profiles.setName(addr, 'trout')
profiles.setName(addr, 'salmon')
assert.equal(profiles.getName(addr), 'salmon')
})
test('different addresses keep independent names', () => {
const profiles = new Profiles()
const addr1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const addr2 = 'bitcoincash:qq0ktdlgekdszmxhmg7y6a90t9dpj6p0pg3gctn9e'
profiles.setName(addr1, 'trout')
profiles.setName(addr2, 'salmon')
assert.equal(profiles.getName(addr1), 'trout')
assert.equal(profiles.getName(addr2), 'salmon')
})
test('ignores setName for a missing address', () => {
const profiles = new Profiles()
profiles.setName(null, 'trout')
assert.equal(profiles.getName(null), null)
})
+193
View File
@@ -0,0 +1,193 @@
/*
Unit tests for the Set Name Page behavior slice (src/services/set-name-page.js).
Expresses the observable behavior described by specs/set-name.feature:
- setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix
and navigates the user to the account page.
- an empty name is rejected with a validation error; nothing is broadcast.
- an over-long name is rejected with a length error; nothing is broadcast.
- the byte counter counts down from the name limit.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page')
const MAX = MemoSetName.MAX_NAME_BYTES // 77
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
const broadcasts = []
const wallet = {
walletInfo: { cashAddress },
utxos: [{ txid: 'utxo-fee' }],
getUtxos: async function () { return this.utxos },
sendOpReturn: async function (msg, prefix) {
this.broadcasts.push({ msg, prefix })
if (this.failWith) throw new Error(this.failWith)
return 'setname-txid'
}
}
wallet.broadcasts = broadcasts
return wallet
}
function fakeProfiles () {
const names = {}
return { names, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null }
}
function build () {
const wallet = fakeWallet()
const profiles = fakeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
const navigations = []
const page = new SetNamePage({
memoSetName,
navigate: (path) => navigations.push(path)
})
return { wallet, profiles, memoSetName, page, navigations }
}
test('SET_NAME_PATH and ACCOUNT_PATH constants', () => {
assert.equal(SetNamePage.SET_NAME_PATH, '/memo/set-name')
assert.equal(SetNamePage.ACCOUNT_PATH, '/account')
})
test('the byte counter counts down from the name limit for an empty name', () => {
const { page } = build()
page.setInput('')
assert.equal(page.remainingCount(), MAX)
})
test('the byte counter counts multi-byte characters by bytes, not characters', () => {
const { page } = build()
page.setInput('é')
assert.equal(page.remainingCount(), MAX - 2)
})
test('the byte counter reaches zero at the byte limit with multi-byte characters', () => {
const { page } = build()
page.setInput('é'.repeat(38))
assert.equal(page.remainingCount(), 1)
})
test('the byte counter counts down from the name limit for a short name', () => {
const { page } = build()
page.setInput('trout')
assert.equal(page.remainingCount(), MAX - 5)
})
test('the byte counter reaches zero at the name limit', () => {
const { page } = build()
page.setInput('x'.repeat(MAX))
assert.equal(page.remainingCount(), 0)
})
test('setting a valid name broadcasts the Memo set-name prefix and navigates to the account page', async () => {
const { wallet, profiles, page, navigations } = build()
page.setInput('trout')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(page.settingName, false)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d01')
assert.equal(wallet.broadcasts[0].msg, 'trout')
assert.deepEqual(navigations, ['/account'])
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout')
})
test('setting an empty name is rejected with a validation error and nothing is broadcast', async () => {
const { wallet, profiles, page, navigations } = build()
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'name_validation')
assert.equal(page.submitError, 'name_validation')
assert.equal(page.settingName, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
assert.deepEqual(navigations, [])
})
test('setting an over-long name is rejected with a length error and nothing is broadcast', async () => {
const { wallet, profiles, page, navigations } = build()
page.setInput('y'.repeat(MAX + 1))
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'name_length')
assert.equal(page.submitError, 'name_length')
assert.equal(page.settingName, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
assert.deepEqual(navigations, [])
})
test('the set name page starts idle (not setting name)', () => {
const { page } = build()
assert.equal(page.settingName, false)
})
test('setting name is true while a submit is in flight and false once it settles', async () => {
const wallet = fakeWallet()
const profiles = fakeProfiles()
let resolveSend
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
const page = new SetNamePage({
memoSetName: new MemoSetName({ wallet, profiles }),
navigate: () => {}
})
page.setInput('trout')
assert.equal(page.settingName, false)
const pending = page.submit()
assert.equal(page.settingName, true)
await new Promise((resolve) => setImmediate(resolve))
assert.equal(typeof resolveSend, 'function')
resolveSend('in-flight-txid')
await pending
assert.equal(page.settingName, false)
})
test('submitting without a memo set-name handler reports an error and does not navigate', async () => {
const navigations = []
const page = new SetNamePage({ navigate: (p) => navigations.push(p) })
page.setInput('trout')
const result = await page.submit()
assert.equal(result.ok, false)
assert.deepEqual(navigations, [])
})
test('a failed broadcast surfaces the real error and does not navigate', async () => {
const wallet = fakeWallet()
const profiles = fakeProfiles()
wallet.failWith = 'BCH UTXO list is empty'
const navigations = []
const page = new SetNamePage({
memoSetName: new MemoSetName({ wallet, profiles }),
navigate: (p) => navigations.push(p)
})
page.setInput('trout')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /BCH UTXO list is empty/)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d01')
assert.deepEqual(navigations, [])
})