mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
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:
+119
-2
@@ -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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+50
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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} />} />
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -151,4 +151,4 @@ function PostFeedItem ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PostFeedItem
|
export default PostFeedItem
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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, [])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user