diff --git a/src/services/memo-action.js b/src/services/memo-action.js new file mode 100644 index 0000000..d14e561 --- /dev/null +++ b/src/services/memo-action.js @@ -0,0 +1,70 @@ +/* + Shared base for Memo protocol actions that broadcast an OP_RETURN + transaction through a wallet and reflect the result on an injected store. + + Subclasses supply the protocol-specific pieces: + prefix - hex protocol prefix (e.g. 0x6d02 for a post) + walletRequiredMsg - error message when no wallet is present + lengthMessage - error text for an over-length value + emptyMessage - error text for an empty value + lengthCode - error code for an over-length value + validationCode - error code for an empty value + isTooLong(value) - true when the value exceeds the action's limit + reflect(txid, value) - record the broadcast result on the injected store +*/ + +class MemoAction { + constructor (deps = {}) { + this.wallet = deps.wallet + } + + // Validate a candidate value. + // Returns { ok: true } or { ok: false, type: 'validation' | 'length' }. + validate (value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return { ok: false, type: 'validation' } + } + + if (this.isTooLong(value)) { + return { ok: false, type: 'length' } + } + + return { ok: true } + } + + // Compose and broadcast the action for the given value. + // Resolves with the transaction id, or rejects with a typed error. + async broadcast (value) { + const check = this.validate(value) + this._throwIfInvalid(check) + + if (!this.wallet) { + throw new Error(this.walletRequiredMsg) + } + + // Refresh the wallet's spendable UTXO store so the broadcast has inputs. + await this.wallet.getUtxos() + + // The wallet's public sendOpReturn(msg, prefix) resolves walletInfo and its + // own spendable UTXOs internally, so only the value and prefix are passed. + const txid = await this.wallet.sendOpReturn(value, this.prefix) + + // Reflect the result on the injected store once broadcast succeeds. + this.reflect(txid, value) + + return txid + } + + // Throw the appropriate typed error when a value fails validation. + _throwIfInvalid (check) { + if (check.ok) return + + const err = new Error( + check.type === 'length' ? this.lengthMessage : this.emptyMessage + ) + err.code = check.type === 'length' ? this.lengthCode : this.validationCode + throw err + } +} + +module.exports = MemoAction diff --git a/src/services/memo-post.js b/src/services/memo-post.js index bc6e935..c0b2cd7 100644 --- a/src/services/memo-post.js +++ b/src/services/memo-post.js @@ -16,68 +16,36 @@ 218 rejected) */ +const MemoAction = require('./memo-action') + const MEMO_POST_PREFIX = '6d02' const MAX_MEMO_CHARS = 217 -class MemoPost { +class MemoPost extends MemoAction { constructor (deps = {}) { - this.wallet = deps.wallet + super(deps) this.feed = deps.feed + this.prefix = MEMO_POST_PREFIX + this.walletRequiredMsg = 'Memo post requires a wallet.' + this.lengthMessage = `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.` + this.emptyMessage = 'Memo must not be empty.' + this.lengthCode = 'memo_length' + this.validationCode = 'memo_validation' } - // Validate a candidate memo message. - // Returns { ok: true } or { ok: false, type: 'validation' | 'length' }. - validate (message) { - if (typeof message !== 'string' || message.trim().length === 0) { - return { ok: false, type: 'validation' } - } - - if (message.length > MAX_MEMO_CHARS) { - return { ok: false, type: 'length' } - } - - return { ok: true } + // A memo is over-length when it exceeds the character limit. + isTooLong (message) { + return message.length > MAX_MEMO_CHARS } // Compose and broadcast a Memo post for the given message. // Resolves with the transaction id, or rejects with a typed error. async post (message) { - const check = this.validate(message) - this._throwIfInvalid(check) - - if (!this.wallet) { - throw new Error('Memo post requires a wallet.') - } - - // Refresh the wallet's spendable UTXO store so the broadcast has inputs. - await this.wallet.getUtxos() - - // The wallet's public sendOpReturn(msg, prefix) resolves walletInfo and - // its own spendable UTXOs internally, so only the message and Memo post - // prefix are passed here. - const txid = await this.wallet.sendOpReturn(message, MEMO_POST_PREFIX) - - // Reflect the new post in the feed once broadcast succeeds. - this._reflectPost(txid, message) - - return txid - } - - // Throw the appropriate typed error when a memo fails validation. - _throwIfInvalid (check) { - if (check.ok) return - - const err = new Error( - check.type === 'length' - ? `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.` - : 'Memo must not be empty.' - ) - err.code = check.type === 'length' ? 'memo_length' : 'memo_validation' - throw err + return this.broadcast(message) } // Record the new post on the injected feed when one is present. - _reflectPost (txid, message) { + reflect (txid, message) { if (this.feed && typeof this.feed.addPost === 'function') { this.feed.addPost({ txid, @@ -94,5 +62,5 @@ MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS module.exports = MemoPost // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-26T00:06:35.333Z","module_hash":"600c2edb145b16db5e313a2911fe164a2c08731346f2a67f52bca18827d8081e","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":23,"end_line":26,"hash":"73596685cdf614a4aa3bb3ab2ee2eec1c080e41ef8c56053a521eb07ca5c7d48"},{"id":"func/MemoPost.validate","name":"MemoPost.validate","line":30,"end_line":40,"hash":"2e45fb32d480e36e04ac61c3fb414849d9daa640c5ac366ee1363be4c3903fd0"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":44,"end_line":67,"hash":"6a817a7eceb24e9e4eb9689345ea3ef6456e8b872bff00a0587bddae8060ead2"},{"id":"func/MemoPost._throwIfInvalid","name":"MemoPost._throwIfInvalid","line":70,"end_line":80,"hash":"e01932c7c343519cc8dd52d3e29b695783c6cdb7e84368e193140827c26bb39c"},{"id":"func/MemoPost._reflectPost","name":"MemoPost._reflectPost","line":83,"end_line":91,"hash":"36e9b77ac19b8a0c598e02f438c3d2ac1f6b7495cf6e28d9546d064ce63f861a"}]} +// {"version":1,"tested_at":"2026-08-26T02:54:15.221Z","module_hash":"7060e3997af5f6340180385c1e96e055614280683bffdeb737e49c37ad6ce946","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":25,"end_line":34,"hash":"f1843343deb758b304364862c2c58af3a8a5b03e3d8a70f3056c8679e869a9ed"},{"id":"func/MemoPost.isTooLong","name":"MemoPost.isTooLong","line":37,"end_line":39,"hash":"833f9f66eae849df0248d0c696c95c767a121b394b1c728bc3ec675668dff5de"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":43,"end_line":45,"hash":"26d8e84b520fae1928f7f72215e6ed95f7219c14266c99b710e2af5373cb6faf"},{"id":"func/MemoPost.reflect","name":"MemoPost.reflect","line":48,"end_line":56,"hash":"87e2168a71309a572c60b63f382dd6f681cb28ed543090dbde399e29556cfcdf"}]} // mutate4javascript-manifest-end diff --git a/src/services/memo-set-name.js b/src/services/memo-set-name.js index 14fc17d..5f9fb3f 100644 --- a/src/services/memo-set-name.js +++ b/src/services/memo-set-name.js @@ -16,65 +16,36 @@ MAX_NAME_BYTES : maximum allowed name length (77 bytes per memo.sv) */ +const MemoAction = require('./memo-action') + const MEMO_SET_NAME_PREFIX = '6d01' const MAX_NAME_BYTES = 77 -class MemoSetName { +class MemoSetName extends MemoAction { constructor (deps = {}) { - this.wallet = deps.wallet + super(deps) this.profiles = deps.profiles + this.prefix = MEMO_SET_NAME_PREFIX + this.walletRequiredMsg = 'Memo set name requires a wallet.' + this.lengthMessage = `Name is too long. Maximum is ${MAX_NAME_BYTES} bytes.` + this.emptyMessage = 'Name must not be empty.' + this.lengthCode = 'name_length' + this.validationCode = 'name_validation' } - // 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 } + // A name is over-length when it exceeds the byte limit. + isTooLong (name) { + return Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES } // 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 + return this.broadcast(name) } // Record the new name on the injected profile store when one is present. - _reflectName (name) { + reflect (txid, name) { if (this.profiles && typeof this.profiles.setName === 'function') { this.profiles.setName(this.wallet.walletInfo.cashAddress, name) } diff --git a/src/services/new-post.js b/src/services/new-post.js index 1e0d1d3..f3fe80a 100644 --- a/src/services/new-post.js +++ b/src/services/new-post.js @@ -13,21 +13,20 @@ adapter boundaries. */ +const PageController = require('./page-controller') const MemoPost = require('./memo-post') const NEW_POST_PATH = '/posts/new' const RECENT_FEED_PATH = '/posts/recent' -class NewPostPage { +class NewPostPage extends PageController { constructor (deps = {}) { + super(deps) this.memoPost = deps.memoPost || null - this.navigate = deps.navigate || (() => {}) this.menuLinks = deps.menuLinks || [] - - this.input = '' - this.submitError = null - this.broadcastError = null this.posting = false + this.successPath = RECENT_FEED_PATH + this.validationCodes = ['memo_validation', 'memo_length'] // The navigation menu links to the new post page. this.addMenuLink(NEW_POST_PATH) @@ -44,51 +43,22 @@ class NewPostPage { return this.menuLinks.includes(path) } - // Set the draft memo text and update the counter. - setInput (text) { - this.input = typeof text === 'string' ? text : '' - return this - } - // Characters remaining before the memo limit is reached. remainingCount () { return MemoPost.MAX_MEMO_CHARS - this.input.length } - // Validate and post the current draft. On success, navigate to the recent - // feed. On failure, record the typed error and stay on the page. Resolves - // with a result object. - async submit () { - this.posting = true - this.submitError = null - this.broadcastError = null - - try { - if (!this.memoPost) { - throw new Error('New post requires a memo post handler.') - } - - const txid = await this.memoPost.post(this.input) - this.navigate(RECENT_FEED_PATH) - this.posting = false - return { ok: true, txid } - } catch (err) { - return this._handleSubmitFailure(err) - } + // Set the in-flight posting flag. + _setBusy (value) { + this.posting = value } - // 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 === 'memo_validation' || err.code === 'memo_length') { - this.submitError = err.code - } else { - this.broadcastError = err.message || String(err) - this.submitError = 'broadcast' + // Run the memo post action for the current input. + async _perform (input) { + if (!this.memoPost) { + throw new Error('New post requires a memo post handler.') } - this.posting = false - return { ok: false, error: this.submitError, message: this.broadcastError } + return this.memoPost.post(input) } } @@ -98,5 +68,5 @@ NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH module.exports = NewPostPage // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-26T00:39:54.163Z","module_hash":"8d50d002e9c6094a1bd2d6e764023c942b1eb0f085b019255dc80f0a72ab1ec6","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":22,"end_line":34,"hash":"d61d01986c51dc4ed4185594fa3e35612924846db321a7107aaec191d17c419d"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":37,"end_line":40,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":43,"end_line":45,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.setInput","name":"NewPostPage.setInput","line":48,"end_line":51,"hash":"595484662b7ca07ef5eef5cebbff06309242552d9b4d15687df02f260ba88244"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":54,"end_line":56,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":61,"end_line":78,"hash":"c280ee1244bcb80c3a9ffe4f52befc8a05629ec879ef9d86666ea11f493b3c5b"},{"id":"func/NewPostPage._handleSubmitFailure","name":"NewPostPage._handleSubmitFailure","line":83,"end_line":92,"hash":"b13d8cd6b48b1f42d72e0031cabcaa00bb78b813f42250517d711f0d6fb23126"}]} +// {"version":1,"tested_at":"2026-08-26T02:54:16.145Z","module_hash":"45d772cfe40b8a34018092abbe924c01cbde741c5cbaa737a283ba35214a99ff","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":23,"end_line":33,"hash":"686653c181941b27c19cd8c1f9fa7b3fee50db6e19c1e39d0db79e6bfbe52a81"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":36,"end_line":39,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":42,"end_line":44,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":47,"end_line":49,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":52,"end_line":54,"hash":"af4c51d75a9bbfc4d8c2566414ee950704d83831ad915ee04eea8bf2fab65a3e"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":57,"end_line":62,"hash":"e66f893a4913b5d6f3cc4fcc1ad58e21557b4a316f0150050c961e3ece237795"}]} // mutate4javascript-manifest-end diff --git a/src/services/page-controller.js b/src/services/page-controller.js new file mode 100644 index 0000000..7f83ce1 --- /dev/null +++ b/src/services/page-controller.js @@ -0,0 +1,59 @@ +/* + Shared base for page controllers that compose and submit a single action, + surface validation/broadcast errors, and navigate on success. + + Subclasses supply the page-specific pieces: + successPath - path to navigate to on success + validationCodes - error codes that represent local validation failures + _setBusy(value) - set the page's in-flight flag + _perform(input) - run the action for the current input, resolving with txid +*/ + +class PageController { + constructor (deps = {}) { + this.navigate = deps.navigate || (() => {}) + this.input = '' + this.submitError = null + this.broadcastError = null + } + + // Set the draft input. + setInput (text) { + this.input = typeof text === 'string' ? text : '' + return this + } + + // Validate and submit the current input. On success, navigate to the success + // path. On failure, record the typed error and stay on the page. Resolves + // with a result object. + async submit () { + this._setBusy(true) + this.submitError = null + this.broadcastError = null + + try { + const txid = await this._perform(this.input) + this.navigate(this.successPath) + this._setBusy(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 (this.validationCodes.includes(err.code)) { + this.submitError = err.code + } else { + this.broadcastError = err.message || String(err) + this.submitError = 'broadcast' + } + this._setBusy(false) + return { ok: false, error: this.submitError, message: this.broadcastError } + } +} + +module.exports = PageController diff --git a/src/services/set-name-page.js b/src/services/set-name-page.js index fba397c..cf5ded9 100644 --- a/src/services/set-name-page.js +++ b/src/services/set-name-page.js @@ -13,26 +13,19 @@ adapter boundaries. */ +const PageController = require('./page-controller') const MemoSetName = require('./memo-set-name') const SET_NAME_PATH = '/memo/set-name' const ACCOUNT_PATH = '/account' -class SetNamePage { +class SetNamePage extends PageController { constructor (deps = {}) { + super(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 + this.successPath = ACCOUNT_PATH + this.validationCodes = ['name_validation', 'name_length'] } // Bytes remaining before the name limit is reached. @@ -40,40 +33,17 @@ class SetNamePage { 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) - } + // Set the in-flight setting-name flag. + _setBusy (value) { + this.settingName = value } - // 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' + // Run the memo set-name action for the current input. + async _perform (input) { + if (!this.memoSetName) { + throw new Error('Set name requires a memo set-name handler.') } - this.settingName = false - return { ok: false, error: this.submitError, message: this.broadcastError } + return this.memoSetName.setName(input) } } diff --git a/test/property/set-name.property.test.js b/test/property/set-name.property.test.js new file mode 100644 index 0000000..fe2210a --- /dev/null +++ b/test/property/set-name.property.test.js @@ -0,0 +1,146 @@ +/* + Property tests for the Set Name behavior slice. + + These assert useful invariants that unit tests cover only at a few fixed + points: + - Set-name validation classification is stable across a broad input range: + any non-blank string up to the byte limit is accepted, any longer string + is rejected with a length error, and blank/non-string input is a + validation error. + - The Set Name byte counter conserves its relationship to input byte + length: Buffer.byteLength(input) + remainingCount() === MAX_NAME_BYTES + for any string. + - setInput round-trips the exact draft text. + - A broadcast failure surfaces the error and never navigates. +*/ + +'use strict' + +const test = require('node:test') + +const { seededRandom, forAll, makeStringGen } = require('./harness') + +const MemoSetName = require('../../src/services/memo-set-name') +const SetNamePage = require('../../src/services/set-name-page') + +const MAX = MemoSetName.MAX_NAME_BYTES // 77 + +const rng = seededRandom(20260827) +const stringOf = makeStringGen(rng) + +function buildPage () { + return new SetNamePage({ + memoSetName: new MemoSetName({}), + navigate: () => {} + }) +} + +// A fake wallet recording broadcast attempts; fails when failWith is set. +function fakeWallet () { + const wallet = { + walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' }, + utxos: [{ txid: 'utxo-fee' }], + getUtxos: async function () { return this.utxos }, + sendOpReturn: async function (msg, prefix) { + if (this.failWith) throw new Error(this.failWith) + return 'prop-txid' + } + } + return wallet +} + +function fakeProfiles () { + const names = new Map() + return { + names, + setName: (addr, name) => names.set(addr, name), + getName: (addr) => names.get(addr) || null + } +} + +test('set-name validation: any non-blank string at or below the byte limit is valid', async () => { + await forAll( + (i) => { + const len = 1 + Math.floor(rng() * MAX) // 1..MAX + return stringOf(len) + }, + (name) => { + // A random ASCII string may occasionally be all whitespace; whitespace-only + // input is a validation error, so only assert for non-blank strings. + if (name.trim().length === 0) return true + const result = new MemoSetName({}).validate(name) + return result.ok === true + }, + { label: 'valid byte length' } + ) +}) + +test('set-name validation: any string above the byte limit is a length error', async () => { + await forAll( + (i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX + (name) => { + const result = new MemoSetName({}).validate(name) + return result.ok === false && result.type === 'length' + }, + { label: 'over-long rejected as length' } + ) +}) + +test('set-name validation: blank and non-string input are validation errors', async () => { + await forAll( + (i) => (i % 2 === 0 ? ' ' : null), + (name) => { + const result = new MemoSetName({}).validate(name) + return result.ok === false && result.type === 'validation' + }, + { label: 'blank/non-string rejected as validation' } + ) +}) + +test('byte counter conserves byte length: remaining === MAX - byteLength(input)', async () => { + await forAll( + (i) => stringOf(Math.floor(rng() * (MAX + 8))), + (name) => { + const page = buildPage() + page.setInput(name) + return page.remainingCount() === MAX - Buffer.byteLength(name, 'utf8') + }, + { label: 'byte counter conservation' } + ) +}) + +test('setInput round-trips the draft text exactly', async () => { + await forAll( + (i) => stringOf(Math.floor(rng() * 50)), + (name) => { + const page = buildPage() + page.setInput(name) + return page.input === name + }, + { label: 'setInput round-trip' } + ) +}) + +test('a broadcast failure surfaces the error and never navigates', async () => { + await forAll( + (i) => ({ name: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }), + ({ name, failWith }) => { + const wallet = fakeWallet() + wallet.failWith = failWith + const navigations = [] + const page = new SetNamePage({ + memoSetName: new MemoSetName({ wallet, profiles: fakeProfiles() }), + navigate: (p) => navigations.push(p) + }) + page.setInput(name) + + return page.submit().then((result) => { + if (result.ok) return false + if (page.submitError !== 'broadcast') return false + if (!page.broadcastError || !page.broadcastError.includes('boom')) return false + return navigations.length === 0 + }) + }, + { label: 'broadcast failure does not navigate' } + ) +})