From dedb7a1343d3f3e4e6b8d40265dd98f167b8c3ed Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 14:40:13 -0700 Subject: [PATCH 1/5] Fix txid wire encoding and add txid repair utility Client likes, replies, poll options, and poll votes now embed the referenced txid in little-endian wire order, matching the indexer's byte reversal. Add a psf-memo-db repair library and CLI that rewrites byte-reversed references in likes, postParents, pollOptions, and pollVotes and rebuilds the postLikes and postChildren indexes, leaving correct and unknown references untouched and staying idempotent. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 8 +- psf-memo-client/src/services/hex.js | 15 +- psf-memo-client/src/services/memo-like.js | 4 +- psf-memo-client/src/services/memo-reply.js | 5 +- psf-memo-client/test/unit/hex.test.js | 28 ++- psf-memo-client/test/unit/memo-like.test.js | 62 +++++ .../test/unit/memo-poll-option.test.js | 3 +- .../test/unit/memo-poll-vote.test.js | 3 +- psf-memo-client/test/unit/memo-reply.test.js | 61 +++++ psf-memo-db/acceptance/lib/handlers.js | 131 +++++++++- psf-memo-db/src/lib/repair-txid-encoding.js | 92 +++++++ .../unit/lib/repair-txid-encoding.unit.js | 225 ++++++++++++++++++ psf-memo-db/util/txid/repair-txid-encoding.js | 92 +++++++ 13 files changed, 714 insertions(+), 15 deletions(-) create mode 100644 psf-memo-client/test/unit/memo-like.test.js create mode 100644 psf-memo-client/test/unit/memo-reply.test.js create mode 100644 psf-memo-db/src/lib/repair-txid-encoding.js create mode 100644 psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js create mode 100644 psf-memo-db/util/txid/repair-txid-encoding.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 4c5fa34..b46f596 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -545,16 +545,18 @@ function createWorld () { } // Decode a raw reply payload into its parent txid (hex) and reply text. +// The wire stores the txid little-endian, so reverse it into display order. function decodeReplyPayload (raw) { const buf = Buffer.from(raw) - const parentTxid = buf.slice(0, 32).toString('hex') + const parentTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex') const text = buf.slice(32).toString('utf8') return { parentTxid, text } } -// Decode a raw like payload back into the liked post txid (hex). +// Decode a raw like payload back into the liked post txid (hex) in display +// order. The wire stores the txid little-endian, so reverse it. function decodeLikeTxid (raw) { - return Buffer.from(raw).toString('hex') + return Buffer.from(raw).reverse().toString('hex') } // Resolve a literal value or a placeholder from the example store. diff --git a/psf-memo-client/src/services/hex.js b/psf-memo-client/src/services/hex.js index 5c35922..0d91383 100644 --- a/psf-memo-client/src/services/hex.js +++ b/psf-memo-client/src/services/hex.js @@ -25,10 +25,19 @@ function hexToBytes (hex, byteLength = 32, label = 'Value') { return bytes } +// Decode a 32-byte txid and reverse it into Memo protocol wire order. Bitcoin +// txids are displayed big-endian but embedded in OP_RETURN payloads as +// little-endian bytes, so callers pass the display hex and receive the wire +// bytes. +function txidToWireBytes (txid, label = 'Value') { + return hexToBytes(txid, 32, label).reverse() +} + // Build the raw OP_RETURN payload for a txid-referencing Memo action: the -// given 32-byte txid followed by a UTF-8 encoded value. +// given 32-byte txid in little-endian wire order followed by a UTF-8 encoded +// value. function buildTxidTextPayload (txid, text) { - const txidBytes = hexToBytes(txid, 32, 'Poll txid') + const txidBytes = txidToWireBytes(txid, 'Poll txid') const textBytes = new TextEncoder().encode(text) const raw = new Uint8Array(txidBytes.length + textBytes.length) raw.set(txidBytes, 0) @@ -36,7 +45,7 @@ function buildTxidTextPayload (txid, text) { return raw } -module.exports = { hexToBytes, buildTxidTextPayload } +module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPayload } // mutate4javascript-manifest-begin // {"version":1,"tested_at":"2026-08-28T22:43:42.484Z","module_hash":"7f3445b7df4d792113f2736232f69faee044f71f6b4a5985224615b36909607b","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":12,"end_line":26,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/buildTxidTextPayload","name":"buildTxidTextPayload","line":30,"end_line":37,"hash":"542a47a13d30c845b7ac9a04079d7a131ece3368a604840ffef75adf97b11f7d"}]} diff --git a/psf-memo-client/src/services/memo-like.js b/psf-memo-client/src/services/memo-like.js index cff2127..55488c7 100644 --- a/psf-memo-client/src/services/memo-like.js +++ b/psf-memo-client/src/services/memo-like.js @@ -23,7 +23,7 @@ */ const MemoAction = require('./memo-action') -const { hexToBytes } = require('./hex') +const { hexToBytes, txidToWireBytes } = require('./hex') const MEMO_LIKE_PREFIX = '6d04' const DUST_LIMIT_SATS = 3000 @@ -136,7 +136,7 @@ class MemoLike extends MemoAction { this.validateTip(tipSats, spendable) this._requireTipAddress(tipSats, authorAddress) - const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid') + const raw = txidToWireBytes(postTxid, 'Post txid') const bchOutput = this._buildTipOutput(tipSats, authorAddress) const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput) diff --git a/psf-memo-client/src/services/memo-reply.js b/psf-memo-client/src/services/memo-reply.js index ec9f0ce..214b982 100644 --- a/psf-memo-client/src/services/memo-reply.js +++ b/psf-memo-client/src/services/memo-reply.js @@ -18,11 +18,10 @@ const MemoAction = require('./memo-action') const { byteLength } = require('./utf8') -const { hexToBytes } = require('./hex') +const { txidToWireBytes } = require('./hex') const MEMO_REPLY_PREFIX = '6d03' const MAX_REPLY_BYTES = 184 -const PARENT_TXID_BYTES = 32 class MemoReply extends MemoAction { static config = { @@ -83,7 +82,7 @@ class MemoReply extends MemoAction { // Build the raw OP_RETURN message payload for a reply. // The protocol wire format is: . function buildReplyPayload (parentTxid, message) { - const parentBytes = hexToBytes(parentTxid, PARENT_TXID_BYTES, 'Parent txid') + const parentBytes = txidToWireBytes(parentTxid, 'Parent txid') const textBytes = new TextEncoder().encode(message) const raw = new Uint8Array(parentBytes.length + textBytes.length) raw.set(parentBytes, 0) diff --git a/psf-memo-client/test/unit/hex.test.js b/psf-memo-client/test/unit/hex.test.js index b94c511..a907922 100644 --- a/psf-memo-client/test/unit/hex.test.js +++ b/psf-memo-client/test/unit/hex.test.js @@ -11,7 +11,11 @@ const test = require('node:test') const assert = require('node:assert/strict') -const { hexToBytes, buildTxidTextPayload } = require('../../src/services/hex') +const { hexToBytes, buildTxidTextPayload, txidToWireBytes } = require('../../src/services/hex') + +// A non-palindromic txid so a missing byte reversal is observable. +const DISPLAY_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +const WIRE_HEX = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301' test('hexToBytes decodes exactly 64 hex characters into 32 bytes', () => { const bytes = hexToBytes('ab'.repeat(32)) @@ -61,3 +65,25 @@ test('buildTxidTextPayload prefixes the raw txid bytes', () => { assert.equal(buf[0], 0xab) assert.equal(buf.slice(32).toString('utf8'), 'hi') }) + +test('txidToWireBytes reverses the display txid into little-endian wire order', () => { + const bytes = txidToWireBytes(DISPLAY_TXID) + + assert.ok(bytes instanceof Uint8Array) + assert.equal(Buffer.from(bytes).toString('hex'), WIRE_HEX) +}) + +test('txidToWireBytes rejects an invalid txid', () => { + assert.throws( + () => txidToWireBytes('zz'.repeat(32)), + /valid hex string/ + ) +}) + +test('buildTxidTextPayload embeds the txid in little-endian wire order', () => { + const raw = buildTxidTextPayload(DISPLAY_TXID, 'hi') + const buf = Buffer.from(raw) + + assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX) + assert.equal(buf.slice(32).toString('utf8'), 'hi') +}) diff --git a/psf-memo-client/test/unit/memo-like.test.js b/psf-memo-client/test/unit/memo-like.test.js new file mode 100644 index 0000000..f2f8dc4 --- /dev/null +++ b/psf-memo-client/test/unit/memo-like.test.js @@ -0,0 +1,62 @@ +/* + Unit tests for the Memo like wire encoding. + + A like action embeds the liked post's transaction txid as 32 raw bytes in + little-endian wire order: the reverse of the 64-character display txid. The + indexer reverses those bytes back into display order, so the client must + reverse before embedding. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const MemoLike = require('../../src/services/memo-like') + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' +const POST_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +const WIRE_HEX = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301' + +function makeWallet () { + return { + walletInfo: { cashAddress: MY_ADDRESS }, + utxos: [{ txid: 'utxo', value: 100000 }], + broadcasts: [], + async getUtxos () { + return this.utxos + }, + async sendOpReturn (msg, prefix, bchOutput = []) { + this.broadcasts.push({ msg, prefix, bchOutput }) + return 'aa'.repeat(32) + } + } +} + +test('like broadcasts the post txid in little-endian wire order', async () => { + const wallet = makeWallet() + const memoLike = new MemoLike({ wallet }) + + await memoLike.like(POST_TXID) + + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, MemoLike.MEMO_LIKE_PREFIX) + const raw = wallet.broadcasts[0].msg + assert.equal(Buffer.from(raw).toString('hex'), WIRE_HEX) +}) + +test('like reflects the post txid in display order on the feed store', async () => { + const wallet = makeWallet() + const added = [] + const feed = { + addLike (like) { + added.push(like) + }, + posts: [] + } + const memoLike = new MemoLike({ wallet, feed }) + + await memoLike.like(POST_TXID) + + assert.equal(added.length, 1) + assert.equal(added[0].postTxid, POST_TXID) +}) diff --git a/psf-memo-client/test/unit/memo-poll-option.test.js b/psf-memo-client/test/unit/memo-poll-option.test.js index 0f48425..ded56f9 100644 --- a/psf-memo-client/test/unit/memo-poll-option.test.js +++ b/psf-memo-client/test/unit/memo-poll-option.test.js @@ -13,7 +13,8 @@ const assert = require('node:assert/strict') const MemoPollOption = require('../../src/services/memo-poll-option') const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' -const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +// A non-palindromic txid so a missing byte reversal is observable. +const POLL_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' function makeWallet (address = MY_ADDRESS) { return { diff --git a/psf-memo-client/test/unit/memo-poll-vote.test.js b/psf-memo-client/test/unit/memo-poll-vote.test.js index 42bb964..7c4674f 100644 --- a/psf-memo-client/test/unit/memo-poll-vote.test.js +++ b/psf-memo-client/test/unit/memo-poll-vote.test.js @@ -13,7 +13,8 @@ const assert = require('node:assert/strict') const MemoPollVote = require('../../src/services/memo-poll-vote') const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' -const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +// A non-palindromic txid so a missing byte reversal is observable. +const POLL_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' function makeWallet (address = MY_ADDRESS) { return { diff --git a/psf-memo-client/test/unit/memo-reply.test.js b/psf-memo-client/test/unit/memo-reply.test.js new file mode 100644 index 0000000..adc9768 --- /dev/null +++ b/psf-memo-client/test/unit/memo-reply.test.js @@ -0,0 +1,61 @@ +/* + Unit tests for the Memo reply wire encoding. + + A reply action embeds the parent transaction txid as 32 raw bytes in + little-endian wire order: the reverse of the 64-character display txid. The + indexer reverses those bytes back into display order, so the client must + reverse before embedding. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const MemoReply = require('../../src/services/memo-reply') + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' +const PARENT_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +const WIRE_HEX = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301' + +function makeWallet () { + return { + walletInfo: { cashAddress: MY_ADDRESS }, + broadcasts: [], + async getUtxos () { + return [] + }, + async sendOpReturn (msg, prefix) { + this.broadcasts.push({ msg, prefix }) + return 'aa'.repeat(32) + } + } +} + +test('reply broadcasts the parent txid in little-endian wire order', async () => { + const wallet = makeWallet() + const memoReply = new MemoReply({ wallet }) + + await memoReply.reply('hello memo', PARENT_TXID) + + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, MemoReply.MEMO_REPLY_PREFIX) + const buf = Buffer.from(wallet.broadcasts[0].msg) + assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX) + assert.equal(buf.slice(32).toString('utf8'), 'hello memo') +}) + +test('reply reflects the parent txid in display order on the thread store', async () => { + const wallet = makeWallet() + const added = [] + const thread = { + addReply (reply) { + added.push(reply) + } + } + const memoReply = new MemoReply({ wallet, thread }) + + await memoReply.reply('hello memo', PARENT_TXID) + + assert.equal(added.length, 1) + assert.equal(added[0].parentTxid, PARENT_TXID) +}) diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index 9ae0880..c103aa2 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -27,6 +27,7 @@ import ListMuted from '../../src/use-cases/list-muted.js' import GetPoll from '../../src/use-cases/get-poll.js' import GetPollOptions from '../../src/use-cases/get-poll-options.js' import GetPollVotes from '../../src/use-cases/get-poll-votes.js' +import { repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance') @@ -204,6 +205,11 @@ async function loadFixture (world, name) { return } + if (name === 'db-with-reversed-txid-references') { + await loadReversedTxidFixture(world) + return + } + if (name === 'many-posts-with-replies') { await loadManyPostsWithReplies(world) return @@ -548,6 +554,37 @@ async function loadPollWithOptionsAndVote (world) { }) } +async function loadReversedTxidFixture (world) { + const displayPost = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + const reversedPost = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301' + const displayPoll = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff' + const reversedPoll = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100' + const unknownPost = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + + await world.adapters.level.postsDb.put(displayPost, { + addr: 'bitcoincash:qaddr-a', text: 'referenced post', seen: 1, blockHeight: 600100 + }) + await world.adapters.level.pollsDb.put(displayPoll, { + addr: 'bitcoincash:qaddr-a', pollType: 1, optionCount: 2, question: 'which?', seen: 2, blockHeight: 600101 + }) + + await world.adapters.level.likesDb.put('like-1', { addr: 'bitcoincash:liker-1', postTxid: reversedPost, seen: 3, blockHeight: 600200 }) + await world.adapters.level.likesDb.put('like-2', { addr: 'bitcoincash:liker-2', postTxid: displayPost, seen: 4, blockHeight: 600201 }) + await world.adapters.level.likesDb.put('like-3', { addr: 'bitcoincash:liker-3', postTxid: unknownPost, seen: 5, blockHeight: 600202 }) + await world.adapters.level.postLikesDb.put(`${reversedPost}:like-1`, { postTxid: reversedPost, txid: 'like-1' }) + await world.adapters.level.postLikesDb.put(`${displayPost}:like-2`, { postTxid: displayPost, txid: 'like-2' }) + + await world.adapters.level.postParentsDb.put('reply-1', { parentTxid: reversedPost, childTxid: 'reply-1', blockHeight: 600050 }) + await world.adapters.level.postParentsDb.put('reply-2', { parentTxid: displayPost, childTxid: 'reply-2', blockHeight: 600051 }) + await world.adapters.level.postChildrenDb.put(`${reversedPost}:reply-1`, { parentTxid: reversedPost, childTxid: 'reply-1' }) + await world.adapters.level.postChildrenDb.put(`${displayPost}:reply-2`, { parentTxid: displayPost, childTxid: 'reply-2' }) + + await world.adapters.level.pollOptionsDb.put('option-1', { addr: 'bitcoincash:qaddr-a', pollTxid: reversedPoll, option: 'yes', seen: 6, blockHeight: 600300 }) + await world.adapters.level.pollOptionsDb.put('option-2', { addr: 'bitcoincash:qaddr-b', pollTxid: displayPoll, option: 'no', seen: 7, blockHeight: 600301 }) + await world.adapters.level.pollVotesDb.put('vote-1', { addr: 'bitcoincash:qaddr-a', pollTxid: reversedPoll, comment: 'yes', seen: 8, blockHeight: 600302 }) + await world.adapters.level.pollVotesDb.put('vote-2', { addr: 'bitcoincash:qaddr-b', pollTxid: displayPoll, comment: 'no', seen: 9, blockHeight: 600303 }) +} + async function loadMutes (world) { const muter1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' const muter2 = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a' @@ -592,6 +629,13 @@ const handlers = [ // World is already created with all stores. } }, + { + name: 'db instance with txid repair stores', + pattern: /^a psf-memo-db instance with posts, likes, postLikes, postParents, postChildren, polls, pollOptions, and pollVotes stores$/, + async run () { + // World is already created with all stores. + } + }, { name: 'load fixture', pattern: /^the fixture "(.+)" is loaded into the posts (?:store|and likes stores)$/, @@ -606,6 +650,13 @@ const handlers = [ await loadFixture(world, m[1]) } }, + { + name: 'load bare fixture', + pattern: /^the fixture "(.+)" is loaded$/, + async run (m, example, world) { + await loadFixture(world, m[1]) + } + }, { name: 'run backfill utility', pattern: /^the backfill utility is run$/, @@ -620,6 +671,20 @@ const handlers = [ await backfillIndexes(world) } }, + { + name: 'run txid repair utility', + pattern: /^the txid repair utility is run$/, + async run (m, example, world) { + await repairTxidEncoding(world.adapters.level) + } + }, + { + name: 'run txid repair utility again', + pattern: /^the txid repair utility is run again$/, + async run (m, example, world) { + await repairTxidEncoding(world.adapters.level) + } + }, { name: 'request recent posts', pattern: /^the client requests \/posts\/recent with limit () and offset ()$/, @@ -859,7 +924,7 @@ const handlers = [ }, { name: 'postLikes contains entry', - pattern: /^the postLikes store contains () entry whose key starts with () and ends with ()$/, + pattern: /^the postLikes store contains (<[A-Za-z0-9_]+>|[0-9]+) entry whose key starts with (<[A-Za-z0-9_]+>) and ends with (<[A-Za-z0-9_]+>)$/, async run (m, example, world) { const expectedCount = parseInt(resolveParam(m[1], example), 10) const postTxid = resolveParam(m[2], example) @@ -873,6 +938,70 @@ const handlers = [ } } }, + { + name: 'postChildren contains entry', + pattern: /^the postChildren store contains (<[A-Za-z0-9_]+>|[0-9]+) entry whose key starts with (<[A-Za-z0-9_]+>) and ends with (<[A-Za-z0-9_]+>)$/, + async run (m, example, world) { + const expectedCount = parseInt(resolveParam(m[1], example), 10) + const postTxid = resolveParam(m[2], example) + const replyTxid = resolveParam(m[3], example) + let count = 0 + for await (const [key] of world.adapters.level.postChildrenDb.iterator()) { + if (key.startsWith(`${postTxid}:`) && key.endsWith(`:${replyTxid}`)) count++ + } + if (count !== expectedCount) { + throw new Error(`Expected ${expectedCount} postChildren entry/entries for ${postTxid}/${replyTxid}, got ${count}`) + } + } + }, + { + name: 'likes store maps like to post', + pattern: /^the likes store maps like (<[A-Za-z0-9_]+>) to post (<[A-Za-z0-9_]+>)$/, + async run (m, example, world) { + const likeTxid = resolveParam(m[1], example) + const postTxid = resolveParam(m[2], example) + const like = await world.adapters.level.likesDb.get(likeTxid) + if (like.postTxid !== postTxid) { + throw new Error(`Expected like ${likeTxid} to map to post ${postTxid}, got ${like.postTxid}.`) + } + } + }, + { + name: 'postParents store maps reply to parent', + pattern: /^the postParents store maps reply (<[A-Za-z0-9_]+>) to parent (<[A-Za-z0-9_]+>)$/, + async run (m, example, world) { + const replyTxid = resolveParam(m[1], example) + const postTxid = resolveParam(m[2], example) + const reply = await world.adapters.level.postParentsDb.get(replyTxid) + if (reply.parentTxid !== postTxid) { + throw new Error(`Expected reply ${replyTxid} to map to parent ${postTxid}, got ${reply.parentTxid}.`) + } + } + }, + { + name: 'pollOptions store maps option to poll', + pattern: /^the pollOptions store maps option (<[A-Za-z0-9_]+>) to poll (<[A-Za-z0-9_]+>)$/, + async run (m, example, world) { + const optionTxid = resolveParam(m[1], example) + const pollTxid = resolveParam(m[2], example) + const option = await world.adapters.level.pollOptionsDb.get(optionTxid) + if (option.pollTxid !== pollTxid) { + throw new Error(`Expected poll option ${optionTxid} to map to poll ${pollTxid}, got ${option.pollTxid}.`) + } + } + }, + { + name: 'pollVotes store maps vote to poll', + pattern: /^the pollVotes store maps vote (<[A-Za-z0-9_]+>) to poll (<[A-Za-z0-9_]+>)$/, + async run (m, example, world) { + const voteTxid = resolveParam(m[1], example) + const pollTxid = resolveParam(m[2], example) + const vote = await world.adapters.level.pollVotesDb.get(voteTxid) + if (vote.pollTxid !== pollTxid) { + throw new Error(`Expected poll vote ${voteTxid} to map to poll ${pollTxid}, got ${vote.pollTxid}.`) + } + } + }, { name: 'db instance with follows store', pattern: /^a psf-memo-db instance with a follows store$/, diff --git a/psf-memo-db/src/lib/repair-txid-encoding.js b/psf-memo-db/src/lib/repair-txid-encoding.js new file mode 100644 index 0000000..984a4a7 --- /dev/null +++ b/psf-memo-db/src/lib/repair-txid-encoding.js @@ -0,0 +1,92 @@ +/* + Library to repair byte-reversed txid references in a psf-memo-db. + + Older psf-memo-client broadcasts embedded a referenced txid in big-endian + display order. The indexer expected little-endian wire order, so it reversed + those bytes and stored a reference that is the reverse of the display txid. + This library rewrites a reversed reference to display order and rebuilds the + affected secondary index. + + It leaves records whose reference already points at a known target + untouched, leaves references whose target is unknown in either byte order + untouched, and is idempotent: a second run makes no changes. + + The LevelDB handles are injected so the repair logic stays testable and free + of file-system concerns; the CLI wrapper in util/txid opens the real stores. +*/ + +// Reverse the byte order of a 64-character display txid. Returns null for any +// value that is not a 64-character hex txid. +export function reverseTxid (txid) { + if (typeof txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(txid)) return null + return Buffer.from(txid, 'hex').reverse().toString('hex') +} + +// True when a store has a record at the given key. +async function hasRecord (db, key) { + try { + await db.get(key) + return true + } catch (err) { + if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return false + throw err + } +} + +// Choose the reference in display order. Keep the stored reference when its +// target exists. Otherwise use the reversed reference when that target +// exists. Otherwise return the stored reference unchanged. +export async function correctReference (targetDb, reference) { + if (await hasRecord(targetDb, reference)) return reference + const reversed = reverseTxid(reference) + if (reversed && await hasRecord(targetDb, reversed)) return reversed + return reference +} + +// Repair likes, replies, poll options, and poll votes in place. Returns a +// summary of how many records of each kind were corrected. +export async function repairTxidEncoding (level) { + const summary = { likes: 0, replies: 0, pollOptions: 0, pollVotes: 0 } + + for await (const [likeTxid, like] of level.likesDb.iterator()) { + if (!like.postTxid) continue + const corrected = await correctReference(level.postsDb, like.postTxid) + if (corrected === like.postTxid) continue + + await level.likesDb.put(likeTxid, { ...like, postTxid: corrected }) + await level.postLikesDb.del(`${like.postTxid}:${likeTxid}`) + await level.postLikesDb.put(`${corrected}:${likeTxid}`, { postTxid: corrected, txid: likeTxid }) + summary.likes++ + } + + for await (const [replyTxid, reply] of level.postParentsDb.iterator()) { + if (!reply.parentTxid) continue + const corrected = await correctReference(level.postsDb, reply.parentTxid) + if (corrected === reply.parentTxid) continue + + await level.postParentsDb.put(replyTxid, { ...reply, parentTxid: corrected }) + await level.postChildrenDb.del(`${reply.parentTxid}:${replyTxid}`) + await level.postChildrenDb.put(`${corrected}:${replyTxid}`, { ...reply, parentTxid: corrected }) + summary.replies++ + } + + for await (const [optionTxid, option] of level.pollOptionsDb.iterator()) { + if (!option.pollTxid) continue + const corrected = await correctReference(level.pollsDb, option.pollTxid) + if (corrected === option.pollTxid) continue + + await level.pollOptionsDb.put(optionTxid, { ...option, pollTxid: corrected }) + summary.pollOptions++ + } + + for await (const [voteTxid, vote] of level.pollVotesDb.iterator()) { + if (!vote.pollTxid) continue + const corrected = await correctReference(level.pollsDb, vote.pollTxid) + if (corrected === vote.pollTxid) continue + + await level.pollVotesDb.put(voteTxid, { ...vote, pollTxid: corrected }) + summary.pollVotes++ + } + + return summary +} diff --git a/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js b/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js new file mode 100644 index 0000000..dc30a54 --- /dev/null +++ b/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js @@ -0,0 +1,225 @@ +/* + Unit tests for the txid encoding repair library. + + Older psf-memo-client broadcasts embedded a referenced txid in big-endian + display order. The indexer expected little-endian wire order, so it stored a + byte-reversed reference. The repair library rewrites a reversed reference to + display order and rebuilds the affected secondary index. It leaves + correctly-encoded records untouched, leaves references whose target is + unknown in either byte order untouched, and is idempotent. +*/ + +import { assert } from 'chai' +import { + reverseTxid, + correctReference, + repairTxidEncoding +} from '../../../src/lib/repair-txid-encoding.js' + +const DISPLAY_POST = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +const REVERSED_POST = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301' +const DISPLAY_POLL = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff' +const REVERSED_POLL = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100' +const UNKNOWN = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + +class FakeDb { + constructor () { + this.map = new Map() + } + + async get (key) { + if (!this.map.has(key)) { + const err = new Error(`not found: ${key}`) + err.notFound = true + throw err + } + return this.map.get(key) + } + + async put (key, value) { + this.map.set(key, value) + } + + async del (key) { + this.map.delete(key) + } + + async * iterator () { + for (const [key, value] of this.map) { + yield [key, value] + } + } + + keys () { + return [...this.map.keys()].sort() + } +} + +function makeLevel () { + return { + postsDb: new FakeDb(), + likesDb: new FakeDb(), + postLikesDb: new FakeDb(), + postParentsDb: new FakeDb(), + postChildrenDb: new FakeDb(), + pollsDb: new FakeDb(), + pollOptionsDb: new FakeDb(), + pollVotesDb: new FakeDb() + } +} + +function loadReversedFixture (level) { + level.postsDb.put(DISPLAY_POST, { addr: 'addr-a', text: 'post', blockHeight: 1 }) + level.pollsDb.put(DISPLAY_POLL, { question: 'q', pollType: 1, optionCount: 2, blockHeight: 2 }) + + level.likesDb.put('like-1', { postTxid: REVERSED_POST, addr: 'addr-b', blockHeight: 3 }) + level.likesDb.put('like-2', { postTxid: DISPLAY_POST, addr: 'addr-c', blockHeight: 4 }) + level.likesDb.put('like-3', { postTxid: UNKNOWN, addr: 'addr-d', blockHeight: 5 }) + level.postLikesDb.put(`${REVERSED_POST}:like-1`, { postTxid: REVERSED_POST, txid: 'like-1' }) + level.postLikesDb.put(`${DISPLAY_POST}:like-2`, { postTxid: DISPLAY_POST, txid: 'like-2' }) + + level.postParentsDb.put('reply-1', { parentTxid: REVERSED_POST, childTxid: 'reply-1', blockHeight: 6 }) + level.postParentsDb.put('reply-2', { parentTxid: DISPLAY_POST, childTxid: 'reply-2', blockHeight: 7 }) + level.postChildrenDb.put(`${REVERSED_POST}:reply-1`, { parentTxid: REVERSED_POST, childTxid: 'reply-1' }) + level.postChildrenDb.put(`${DISPLAY_POST}:reply-2`, { parentTxid: DISPLAY_POST, childTxid: 'reply-2' }) + + level.pollOptionsDb.put('option-1', { pollTxid: REVERSED_POLL, option: 'yes', blockHeight: 8 }) + level.pollOptionsDb.put('option-2', { pollTxid: DISPLAY_POLL, option: 'no', blockHeight: 9 }) + level.pollVotesDb.put('vote-1', { pollTxid: REVERSED_POLL, comment: 'yes', blockHeight: 10 }) + level.pollVotesDb.put('vote-2', { pollTxid: DISPLAY_POLL, comment: 'no', blockHeight: 11 }) +} + +describe('#RepairTxidEncoding', () => { + describe('reverseTxid', () => { + it('reverses the byte order of a display txid', () => { + assert.equal(reverseTxid(DISPLAY_POST), REVERSED_POST) + }) + + it('returns null for a value that is not a 64-character hex txid', () => { + assert.isNull(reverseTxid('not-a-txid')) + assert.isNull(reverseTxid(null)) + assert.isNull(reverseTxid('zz'.repeat(32))) + }) + }) + + describe('correctReference', () => { + it('keeps a reference whose target exists in display order', async () => { + const level = makeLevel() + level.postsDb.put(DISPLAY_POST, {}) + assert.equal(await correctReference(level.postsDb, DISPLAY_POST), DISPLAY_POST) + }) + + it('reverses a reference whose reversed form is the existing target', async () => { + const level = makeLevel() + level.postsDb.put(DISPLAY_POST, {}) + assert.equal(await correctReference(level.postsDb, REVERSED_POST), DISPLAY_POST) + }) + + it('keeps an unknown reference unchanged', async () => { + const level = makeLevel() + assert.equal(await correctReference(level.postsDb, UNKNOWN), UNKNOWN) + }) + + it('propagates an unexpected store error', async () => { + const targetDb = { + async get () { + const err = new Error('io failure') + err.code = 'EIO' + throw err + } + } + + let caught = null + try { + await correctReference(targetDb, DISPLAY_POST) + } catch (err) { + caught = err + } + + assert.isNotNull(caught) + assert.equal(caught.code, 'EIO') + }) + }) + + describe('repairTxidEncoding', () => { + it('corrects a reversed like reference and rebuilds postLikes', async () => { + const level = makeLevel() + loadReversedFixture(level) + + await repairTxidEncoding(level) + + assert.equal((await level.likesDb.get('like-1')).postTxid, DISPLAY_POST) + assert.include(level.postLikesDb.keys(), `${DISPLAY_POST}:like-1`) + assert.notInclude(level.postLikesDb.keys(), `${REVERSED_POST}:like-1`) + }) + + it('corrects a reversed reply reference and rebuilds postChildren', async () => { + const level = makeLevel() + loadReversedFixture(level) + + await repairTxidEncoding(level) + + assert.equal((await level.postParentsDb.get('reply-1')).parentTxid, DISPLAY_POST) + assert.include(level.postChildrenDb.keys(), `${DISPLAY_POST}:reply-1`) + assert.notInclude(level.postChildrenDb.keys(), `${REVERSED_POST}:reply-1`) + }) + + it('corrects reversed poll option and vote references', async () => { + const level = makeLevel() + loadReversedFixture(level) + + await repairTxidEncoding(level) + + assert.equal((await level.pollOptionsDb.get('option-1')).pollTxid, DISPLAY_POLL) + assert.equal((await level.pollVotesDb.get('vote-1')).pollTxid, DISPLAY_POLL) + }) + + it('leaves correctly-encoded references unchanged', async () => { + const level = makeLevel() + loadReversedFixture(level) + + await repairTxidEncoding(level) + + assert.equal((await level.likesDb.get('like-2')).postTxid, DISPLAY_POST) + assert.equal((await level.postParentsDb.get('reply-2')).parentTxid, DISPLAY_POST) + assert.equal((await level.pollOptionsDb.get('option-2')).pollTxid, DISPLAY_POLL) + assert.equal((await level.pollVotesDb.get('vote-2')).pollTxid, DISPLAY_POLL) + }) + + it('leaves an unknown reference unchanged', async () => { + const level = makeLevel() + loadReversedFixture(level) + + await repairTxidEncoding(level) + + assert.equal((await level.likesDb.get('like-3')).postTxid, UNKNOWN) + }) + + it('skips records without a reference field', async () => { + const level = makeLevel() + level.likesDb.put('like-empty', { addr: 'addr' }) + level.postParentsDb.put('reply-empty', { childTxid: 'reply-empty' }) + level.pollOptionsDb.put('option-empty', { option: 'x' }) + level.pollVotesDb.put('vote-empty', { comment: 'x' }) + + const summary = await repairTxidEncoding(level) + + assert.deepEqual(summary, { likes: 0, replies: 0, pollOptions: 0, pollVotes: 0 }) + }) + + it('is idempotent', async () => { + const level = makeLevel() + loadReversedFixture(level) + + await repairTxidEncoding(level) + await repairTxidEncoding(level) + + const postLikes = level.postLikesDb.keys() + assert.include(postLikes, `${DISPLAY_POST}:like-1`) + assert.notInclude(postLikes, `${REVERSED_POST}:like-1`) + const postChildren = level.postChildrenDb.keys() + assert.include(postChildren, `${DISPLAY_POST}:reply-1`) + assert.notInclude(postChildren, `${REVERSED_POST}:reply-1`) + }) + }) +}) diff --git a/psf-memo-db/util/txid/repair-txid-encoding.js b/psf-memo-db/util/txid/repair-txid-encoding.js new file mode 100644 index 0000000..4da8bd2 --- /dev/null +++ b/psf-memo-db/util/txid/repair-txid-encoding.js @@ -0,0 +1,92 @@ +/* + Utility: repair byte-reversed txid references in an existing psf-memo-db and + rebuild the affected secondary indexes. + + Older psf-memo-client broadcasts embedded a referenced txid in big-endian + display order. The indexer expected little-endian wire order, so it stored a + reference that is the reverse of the display txid. This utility rewrites + those references to display order for likes, replies, poll options, and poll + votes, and rebuilds the postLikes and postChildren indexes. + + Run from the psf-memo-db repo root on the host that owns the LevelDB files: + + node util/txid/repair-txid-encoding.js + + The repair is idempotent: re-running it makes no further changes. Progress + and a summary are printed to stderr. + + WARNING: + - This script opens the LevelDB files directly. psf-memo-db must NOT be + running, or another process must not hold the database locks. + - Make a backup of leveldb/current before running on a production server: + cp -r leveldb/current leveldb/current-pre-txid-repair-backup +*/ + +import level from 'level' +import * as fs from 'fs' +import * as path from 'path' +import * as url from 'url' +import { repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js' + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) + +const DATA_DIR = process.env.PSF_MEMO_DB_DATA_DIR + ? path.resolve(process.env.PSF_MEMO_DB_DATA_DIR) + : path.resolve(__dirname, '../../leveldb/current') + +// Stores that hold records to scan. They must already exist. +const RECORD_STORES = ['posts', 'polls', 'likes', 'postParents', 'pollOptions', 'pollVotes'] +// Secondary indexes rebuilt by the repair. Create them when missing. +const INDEX_STORES = ['postLikes', 'postChildren'] + +function requiredStorePath (dir, name) { + const storePath = path.join(dir, name) + if (!fs.existsSync(storePath)) { + throw new Error(`Required LevelDB store not found: ${storePath}. Set PSF_MEMO_DB_DATA_DIR to the directory containing the psf-memo-db stores.`) + } + return storePath +} + +async function main () { + console.error(`Using LevelDB data directory: ${DATA_DIR}`) + + const paths = {} + for (const name of RECORD_STORES) { + paths[name] = requiredStorePath(DATA_DIR, name) + } + for (const name of INDEX_STORES) { + paths[name] = path.join(DATA_DIR, name) + } + + console.error('Opening LevelDB stores...') + const dbs = { + postsDb: level(paths.posts, { valueEncoding: 'json' }), + pollsDb: level(paths.polls, { valueEncoding: 'json' }), + likesDb: level(paths.likes, { valueEncoding: 'json' }), + postParentsDb: level(paths.postParents, { valueEncoding: 'json' }), + pollOptionsDb: level(paths.pollOptions, { valueEncoding: 'json' }), + pollVotesDb: level(paths.pollVotes, { valueEncoding: 'json' }), + postLikesDb: level(paths.postLikes, { valueEncoding: 'json', createIfMissing: true }), + postChildrenDb: level(paths.postChildren, { valueEncoding: 'json', createIfMissing: true }) + } + + try { + console.error('Repairing reversed txid references...') + const summary = await repairTxidEncoding(dbs) + + console.error('\nTxid encoding repair complete.') + console.error(` likes corrected: ${summary.likes}`) + console.error(` replies corrected: ${summary.replies}`) + console.error(` poll options corrected: ${summary.pollOptions}`) + console.error(` poll votes corrected: ${summary.pollVotes}`) + } catch (err) { + console.error('\nTxid encoding repair failed:', err.message) + process.exitCode = 1 + } finally { + for (const db of Object.values(dbs)) { + await db.close().catch(() => {}) + } + } +} + +main() From a76fceee7396973ad1c29b3edd41d9ebba3707de Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 14:59:00 -0700 Subject: [PATCH 2/5] Refactor txid wire encoding and repair library Share the txid-and-text payload builder between hex and reply, extract the repair store loop from repairTxidEncoding to cut CRAP from 13 to 5, correct the poll-services property test to the little-endian wire order, and add property tests for wire round trips and repair idempotence/conservation. By refactorer. --- psf-memo-client/src/services/hex.js | 6 +- psf-memo-client/src/services/memo-reply.js | 15 +- .../property/poll-services.property.test.js | 11 +- .../txid-wire-encoding.property.test.js | 108 ++++++++ psf-memo-client/test/unit/memo-like.test.js | 115 ++++++++ psf-memo-db/src/lib/repair-txid-encoding.js | 98 ++++--- .../repair-txid-encoding.property.test.js | 257 ++++++++++++++++++ 7 files changed, 549 insertions(+), 61 deletions(-) create mode 100644 psf-memo-client/test/property/txid-wire-encoding.property.test.js create mode 100644 psf-memo-db/test/property/repair-txid-encoding.property.test.js diff --git a/psf-memo-client/src/services/hex.js b/psf-memo-client/src/services/hex.js index 0d91383..7c72cea 100644 --- a/psf-memo-client/src/services/hex.js +++ b/psf-memo-client/src/services/hex.js @@ -35,9 +35,9 @@ function txidToWireBytes (txid, label = 'Value') { // Build the raw OP_RETURN payload for a txid-referencing Memo action: the // given 32-byte txid in little-endian wire order followed by a UTF-8 encoded -// value. -function buildTxidTextPayload (txid, text) { - const txidBytes = txidToWireBytes(txid, 'Poll txid') +// value. The label customizes the invalid-txid error message. +function buildTxidTextPayload (txid, text, label = 'Poll txid') { + const txidBytes = txidToWireBytes(txid, label) const textBytes = new TextEncoder().encode(text) const raw = new Uint8Array(txidBytes.length + textBytes.length) raw.set(txidBytes, 0) diff --git a/psf-memo-client/src/services/memo-reply.js b/psf-memo-client/src/services/memo-reply.js index 214b982..1e7d5bc 100644 --- a/psf-memo-client/src/services/memo-reply.js +++ b/psf-memo-client/src/services/memo-reply.js @@ -18,7 +18,7 @@ const MemoAction = require('./memo-action') const { byteLength } = require('./utf8') -const { txidToWireBytes } = require('./hex') +const { buildTxidTextPayload } = require('./hex') const MEMO_REPLY_PREFIX = '6d03' const MAX_REPLY_BYTES = 184 @@ -57,7 +57,7 @@ class MemoReply extends MemoAction { await this.wallet.getUtxos() // Build the raw payload: parent txid bytes followed by UTF-8 message bytes. - const raw = buildReplyPayload(parentTxid, message) + const raw = buildTxidTextPayload(parentTxid, message, 'Parent txid') const txid = await this.wallet.sendOpReturn(raw, this.prefix) // Reflect the result on the injected thread once broadcast succeeds. @@ -79,17 +79,6 @@ class MemoReply extends MemoAction { } } -// Build the raw OP_RETURN message payload for a reply. -// The protocol wire format is: . -function buildReplyPayload (parentTxid, message) { - const parentBytes = txidToWireBytes(parentTxid, 'Parent txid') - const textBytes = new TextEncoder().encode(message) - const raw = new Uint8Array(parentBytes.length + textBytes.length) - raw.set(parentBytes, 0) - raw.set(textBytes, parentBytes.length) - return raw -} - MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES diff --git a/psf-memo-client/test/property/poll-services.property.test.js b/psf-memo-client/test/property/poll-services.property.test.js index fa7ac6d..b3e253a 100644 --- a/psf-memo-client/test/property/poll-services.property.test.js +++ b/psf-memo-client/test/property/poll-services.property.test.js @@ -67,12 +67,11 @@ test('buildTxidTextPayload round-trips the canonical Memo wire format', async () ({ txid, text }) => { const bytes = buildTxidTextPayload(txid, text) if (bytes.length !== 32 + byteLength(text)) return false - // The payload carries the txid's literal 32 bytes in order, followed by - // the UTF-8 bytes of the value. - const expectedTxidBytes = hexToBytes(txid, 32, 'Poll txid') - for (let i = 0; i < 32; i++) { - if (bytes[i] !== expectedTxidBytes[i]) return false - } + // The payload carries the txid in little-endian wire order, followed by + // the UTF-8 bytes of the value. Reversing the wire bytes must recover + // the 64-character display txid. + const wire = Buffer.from(bytes.slice(0, 32)) + if (wire.reverse().toString('hex') !== txid) return false const storedText = Buffer.from(bytes.slice(32)).toString('utf8') return storedText === text }, diff --git a/psf-memo-client/test/property/txid-wire-encoding.property.test.js b/psf-memo-client/test/property/txid-wire-encoding.property.test.js new file mode 100644 index 0000000..bbf0003 --- /dev/null +++ b/psf-memo-client/test/property/txid-wire-encoding.property.test.js @@ -0,0 +1,108 @@ +/* + Property tests for Memo txid wire encoding. + + Unit tests pin the wire bytes of a few fixed txids. These properties pin the + encoding invariants over broad random inputs: + + - Round trip: reversing the wire bytes recovers the display bytes, and + reversing twice is the identity. + - Wire order: the encoded bytes are exactly the reverse of the display + txid's bytes. + - Payload shape: a txid-and-text payload starts with the wire txid and + ends with the UTF-8 text, with no bytes added or dropped. + - Determinism: the same txid always encodes to the same bytes. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { seededRandom, forAll, intGen } = require('./harness') +const { txidToWireBytes, buildTxidTextPayload } = require('../../src/services/hex') + +const HEX_CHARS = '0123456789abcdef' + +function randomTxid (rng) { + let out = '' + for (let i = 0; i < 64; i++) { + out += HEX_CHARS[Math.floor(rng() * HEX_CHARS.length)] + } + return out +} + +function randomText (rng) { + const alphabet = 'abc XYZ!\u00e9\u4e2d\ud83d\ude00' + let out = '' + const length = intGen(rng, 0, 24)() + for (let i = 0; i < length; i++) { + out += alphabet[Math.floor(rng() * alphabet.length)] + } + return out +} + +test('txid wire bytes are the byte reverse of the display txid', async () => { + const rng = seededRandom(20260916) + + await forAll( + () => randomTxid(rng), + (txid) => { + const wire = Buffer.from(txidToWireBytes(txid)).toString('hex') + const expected = Buffer.from(txid, 'hex').reverse().toString('hex') + return wire === expected + }, + { label: 'txidToWireBytes byte order' } + ) +}) + +test('reversing wire bytes twice recovers the display txid', async () => { + const rng = seededRandom(20260917) + + await forAll( + () => randomTxid(rng), + (txid) => { + const bytes = txidToWireBytes(txid) + return Buffer.from(Buffer.from(bytes).reverse()).toString('hex') === txid + }, + { label: 'txidToWireBytes round trip' } + ) +}) + +test('txid encoding is deterministic', async () => { + const rng = seededRandom(20260918) + + await forAll( + () => randomTxid(rng), + (txid) => { + const first = Buffer.from(txidToWireBytes(txid)).toString('hex') + const second = Buffer.from(txidToWireBytes(txid)).toString('hex') + return first === second + }, + { label: 'txidToWireBytes determinism' } + ) +}) + +test('payload embeds the wire txid followed by the UTF-8 text', async () => { + const rng = seededRandom(20260919) + + await forAll( + () => ({ txid: randomTxid(rng), text: randomText(rng) }), + ({ txid, text }) => { + const raw = buildTxidTextPayload(txid, text) + const buf = Buffer.from(raw) + const wire = Buffer.from(txidToWireBytes(txid)).toString('hex') + const expectedText = Buffer.from(text, 'utf8') + + if (buf.length !== 32 + expectedText.length) return false + if (buf.subarray(0, 32).toString('hex') !== wire) return false + return buf.subarray(32).equals(expectedText) + }, + { label: 'buildTxidTextPayload shape' } + ) +}) + +test('a reply payload rejects an invalid txid with the parent label', () => { + assert.throws( + () => buildTxidTextPayload('not-a-txid', 'hi', 'Parent txid'), + /Parent txid must be a 64-character hex string/ + ) +}) diff --git a/psf-memo-client/test/unit/memo-like.test.js b/psf-memo-client/test/unit/memo-like.test.js index f2f8dc4..dd52028 100644 --- a/psf-memo-client/test/unit/memo-like.test.js +++ b/psf-memo-client/test/unit/memo-like.test.js @@ -60,3 +60,118 @@ test('like reflects the post txid in display order on the feed store', async () assert.equal(added.length, 1) assert.equal(added[0].postTxid, POST_TXID) }) + +test('like rejects a caller without a wallet', async () => { + const memoLike = new MemoLike() + + await assert.rejects(() => memoLike.like(POST_TXID), /requires a wallet/) +}) + +test('like rejects an invalid post txid before broadcasting', async () => { + const wallet = makeWallet() + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + () => memoLike.like('not-a-txid'), + (err) => err.code === 'like_validation' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('like rejects a balance below the dust limit', async () => { + const wallet = makeWallet() + wallet.utxos = [{ txid: 'utxo', value: 100 }] + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + () => memoLike.like(POST_TXID), + (err) => err.code === 'like_empty_balance' + ) +}) + +test('like rejects a tip that exceeds the spendable balance', async () => { + const wallet = makeWallet() + wallet.utxos = [{ txid: 'utxo', value: 3000 }] + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + () => memoLike.like(POST_TXID, 4000, MY_ADDRESS), + (err) => err.code === 'like_balance' + ) +}) + +test('like requires an author address when a tip is present', async () => { + const wallet = makeWallet() + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + () => memoLike.like(POST_TXID, 1000), + (err) => err.code === 'like_validation' + ) +}) + +test('_validateTipAmount rejects a non-integer or negative tip', () => { + const memoLike = new MemoLike() + + assert.throws(() => memoLike._validateTipAmount(1.5), (err) => err.code === 'like_validation') + assert.throws(() => memoLike._validateTipAmount(-1), (err) => err.code === 'like_validation') +}) + +test('_validateTipAmount rejects a tip below the dust limit', () => { + const memoLike = new MemoLike() + + assert.throws(() => memoLike._validateTipAmount(1), (err) => err.code === 'like_dust') +}) + +test('_validateTipAmount rejects a tip above the maximum', () => { + const memoLike = new MemoLike() + + assert.throws( + () => memoLike._validateTipAmount(MemoLike.MAX_TIP_SATS + 1), + (err) => err.code === 'like_maximum' + ) +}) + +test('_validateTipAmount accepts zero and a valid tip', () => { + const memoLike = new MemoLike() + + assert.equal(memoLike._validateTipAmount(0), undefined) + assert.equal(memoLike._validateTipAmount(1000), undefined) +}) + +test('getSpendableSats sums the utxoStore bchUtxos shape', () => { + const wallet = { + utxos: { utxoStore: { bchUtxos: [{ satoshis: 1000 }, { amount: 500 }] } } + } + const memoLike = new MemoLike({ wallet }) + + assert.equal(memoLike.getSpendableSats(), 1500) +}) + +test('getSpendableSats is zero without a wallet', () => { + assert.equal(new MemoLike().getSpendableSats(), 0) +}) + +test('like includes a tip output and reflects the tip on the feed', async () => { + const wallet = makeWallet() + const added = [] + const feed = { + addLike (like) { + added.push(like) + }, + posts: [{ txid: POST_TXID, likeCount: 2 }] + } + const memoLike = new MemoLike({ wallet, feed }) + + await memoLike.like(POST_TXID, 1000, MY_ADDRESS) + + assert.deepEqual(wallet.broadcasts[0].bchOutput, [{ address: MY_ADDRESS, amountSat: 1000 }]) + assert.equal(added[0].tipSats, 1000) + assert.equal(feed.posts[0].likeCount, 3) +}) + +test('_buildTipOutput omits the output for a zero tip', () => { + const memoLike = new MemoLike() + + assert.deepEqual(memoLike._buildTipOutput(0, MY_ADDRESS), []) +}) diff --git a/psf-memo-db/src/lib/repair-txid-encoding.js b/psf-memo-db/src/lib/repair-txid-encoding.js index 984a4a7..9da367d 100644 --- a/psf-memo-db/src/lib/repair-txid-encoding.js +++ b/psf-memo-db/src/lib/repair-txid-encoding.js @@ -43,50 +43,70 @@ export async function correctReference (targetDb, reference) { return reference } +// Repair one referencing store in place. Every record whose reference can be +// corrected is rewritten; an optional secondary index rebuilds its entry for +// each corrected record. Returns the number of records corrected. +async function repairStore ({ sourceDb, referenceField, targetDb, index }) { + let correctedCount = 0 + + for await (const [recordTxid, record] of sourceDb.iterator()) { + const reference = record[referenceField] + if (!reference) continue + + const corrected = await correctReference(targetDb, reference) + if (corrected === reference) continue + + await sourceDb.put(recordTxid, { ...record, [referenceField]: corrected }) + + if (index) { + await index.db.del(index.key(reference, recordTxid)) + await index.db.put(index.key(corrected, recordTxid), index.value(record, corrected, recordTxid)) + } + + correctedCount++ + } + + return correctedCount +} + +// A secondary index keyed as :; both the postLikes +// and postChildren indexes use this shape. +function txidIndex (db, value) { + return { + db, + key: (targetTxid, recordTxid) => `${targetTxid}:${recordTxid}`, + value + } +} + // Repair likes, replies, poll options, and poll votes in place. Returns a // summary of how many records of each kind were corrected. export async function repairTxidEncoding (level) { - const summary = { likes: 0, replies: 0, pollOptions: 0, pollVotes: 0 } + const likes = await repairStore({ + sourceDb: level.likesDb, + referenceField: 'postTxid', + targetDb: level.postsDb, + index: txidIndex(level.postLikesDb, (record, postTxid, likeTxid) => ({ postTxid, txid: likeTxid })) + }) - for await (const [likeTxid, like] of level.likesDb.iterator()) { - if (!like.postTxid) continue - const corrected = await correctReference(level.postsDb, like.postTxid) - if (corrected === like.postTxid) continue + const replies = await repairStore({ + sourceDb: level.postParentsDb, + referenceField: 'parentTxid', + targetDb: level.postsDb, + index: txidIndex(level.postChildrenDb, (record, parentTxid) => ({ ...record, parentTxid })) + }) - await level.likesDb.put(likeTxid, { ...like, postTxid: corrected }) - await level.postLikesDb.del(`${like.postTxid}:${likeTxid}`) - await level.postLikesDb.put(`${corrected}:${likeTxid}`, { postTxid: corrected, txid: likeTxid }) - summary.likes++ - } + const pollOptions = await repairStore({ + sourceDb: level.pollOptionsDb, + referenceField: 'pollTxid', + targetDb: level.pollsDb + }) - for await (const [replyTxid, reply] of level.postParentsDb.iterator()) { - if (!reply.parentTxid) continue - const corrected = await correctReference(level.postsDb, reply.parentTxid) - if (corrected === reply.parentTxid) continue + const pollVotes = await repairStore({ + sourceDb: level.pollVotesDb, + referenceField: 'pollTxid', + targetDb: level.pollsDb + }) - await level.postParentsDb.put(replyTxid, { ...reply, parentTxid: corrected }) - await level.postChildrenDb.del(`${reply.parentTxid}:${replyTxid}`) - await level.postChildrenDb.put(`${corrected}:${replyTxid}`, { ...reply, parentTxid: corrected }) - summary.replies++ - } - - for await (const [optionTxid, option] of level.pollOptionsDb.iterator()) { - if (!option.pollTxid) continue - const corrected = await correctReference(level.pollsDb, option.pollTxid) - if (corrected === option.pollTxid) continue - - await level.pollOptionsDb.put(optionTxid, { ...option, pollTxid: corrected }) - summary.pollOptions++ - } - - for await (const [voteTxid, vote] of level.pollVotesDb.iterator()) { - if (!vote.pollTxid) continue - const corrected = await correctReference(level.pollsDb, vote.pollTxid) - if (corrected === vote.pollTxid) continue - - await level.pollVotesDb.put(voteTxid, { ...vote, pollTxid: corrected }) - summary.pollVotes++ - } - - return summary + return { likes, replies, pollOptions, pollVotes } } diff --git a/psf-memo-db/test/property/repair-txid-encoding.property.test.js b/psf-memo-db/test/property/repair-txid-encoding.property.test.js new file mode 100644 index 0000000..e774f30 --- /dev/null +++ b/psf-memo-db/test/property/repair-txid-encoding.property.test.js @@ -0,0 +1,257 @@ +/* + Property tests for the txid encoding repair library. + + Unit tests probe the repair at a fixed fixture. These properties pin the + invariants over broad random inputs: + + - Selection: correctReference prefers the stored reference when its target + exists, falls back to the reversed reference when only that target + exists, and leaves an unknown reference unchanged. + - Involution: reversing a txid twice returns the original display txid. + - Idempotence: a second repair run changes nothing. + - Conservation: repair never adds or drops primary records. + - Resolution: after repair every reference points at an existing target + when either byte order had one. +*/ + +import test from 'node:test' + +import { seededRandom, forAll, intGen, txidGen } from './harness.js' +import { + reverseTxid, + correctReference, + repairTxidEncoding +} from '../../src/lib/repair-txid-encoding.js' + +const rng = seededRandom(20260916) + +class FakeDb { + constructor () { + this.map = new Map() + } + + async get (key) { + if (!this.map.has(key)) { + const err = new Error(`not found: ${key}`) + err.notFound = true + throw err + } + return this.map.get(key) + } + + async put (key, value) { + this.map.set(key, value) + } + + async del (key) { + this.map.delete(key) + } + + async * iterator () { + for (const [key, value] of this.map) { + yield [key, value] + } + } +} + +function makeLevel () { + return { + postsDb: new FakeDb(), + pollsDb: new FakeDb(), + likesDb: new FakeDb(), + postLikesDb: new FakeDb(), + postParentsDb: new FakeDb(), + postChildrenDb: new FakeDb(), + pollOptionsDb: new FakeDb(), + pollVotesDb: new FakeDb() + } +} + +// Snapshot every store as a stable string so a second run can be compared. +function snapshot (level) { + const stores = {} + for (const [name, db] of Object.entries(level)) { + stores[name] = [...db.map.entries()].sort(([a], [b]) => a.localeCompare(b)) + } + return JSON.stringify(stores) +} + +// A reference that is the display target, its reverse, or an unrelated txid. +function referenceTo (rng, targets, unknown) { + if (targets.length === 0) return unknown + const roll = rng() + const target = targets[intGen(rng, 0, targets.length - 1)()] + if (roll < 1 / 3) return target + if (roll < 2 / 3) return reverseTxid(target) + return unknown +} + +// Build a random level with display posts/polls and a mix of correct, +// reversed, and unknown references in each primary store. FakeDb.put mutates +// synchronously, so this builder is synchronous for the property harness. +function buildFixture () { + const level = makeLevel() + const postTxids = [] + const pollTxids = [] + + const postCount = intGen(rng, 0, 5)() + for (let i = 0; i < postCount; i++) { + const txid = txidGen(rng) + postTxids.push(txid) + level.postsDb.put(txid, { addr: `addr-${i}`, text: 'post', blockHeight: i }) + } + + const pollCount = intGen(rng, 0, 4)() + for (let i = 0; i < pollCount; i++) { + const txid = txidGen(rng) + pollTxids.push(txid) + level.pollsDb.put(txid, { question: 'q', pollType: 1, optionCount: 2, blockHeight: i }) + } + + const likeCount = intGen(rng, 0, 6)() + for (let i = 0; i < likeCount; i++) { + const likeTxid = `like-${i}` + const postTxid = referenceTo(rng, postTxids, txidGen(rng)) + level.likesDb.put(likeTxid, { postTxid, addr: 'addr', blockHeight: i }) + level.postLikesDb.put(`${postTxid}:${likeTxid}`, { postTxid, txid: likeTxid }) + } + + const replyCount = intGen(rng, 0, 6)() + for (let i = 0; i < replyCount; i++) { + const replyTxid = `reply-${i}` + const parentTxid = referenceTo(rng, postTxids, txidGen(rng)) + level.postParentsDb.put(replyTxid, { parentTxid, childTxid: replyTxid, blockHeight: i }) + level.postChildrenDb.put(`${parentTxid}:${replyTxid}`, { parentTxid, childTxid: replyTxid }) + } + + const optionCount = intGen(rng, 0, 5)() + for (let i = 0; i < optionCount; i++) { + const pollTxid = referenceTo(rng, pollTxids, txidGen(rng)) + level.pollOptionsDb.put(`option-${i}`, { pollTxid, option: `opt-${i}`, blockHeight: i }) + } + + const voteCount = intGen(rng, 0, 5)() + for (let i = 0; i < voteCount; i++) { + const pollTxid = referenceTo(rng, pollTxids, txidGen(rng)) + level.pollVotesDb.put(`vote-${i}`, { pollTxid, comment: `vote-${i}`, blockHeight: i }) + } + + return { level, postTxids, pollTxids } +} + +function primaryCounts (level) { + return { + likes: level.likesDb.map.size, + replies: level.postParentsDb.map.size, + pollOptions: level.pollOptionsDb.map.size, + pollVotes: level.pollVotesDb.map.size + } +} + +// True when every reference in a store is either a known target or unknown in +// both byte orders. Repair must not leave a resolvable reference unresolved. +function allResolvableOrUnknown (db, targetDb, field) { + for (const record of db.map.values()) { + const reference = record[field] + if (targetDb.map.has(reference)) continue + if (targetDb.map.has(reverseTxid(reference))) return false + } + return true +} + +test('reverseTxid is an involution on valid txids', async () => { + await forAll( + () => txidGen(rng), + (txid) => reverseTxid(reverseTxid(txid)) === txid, + { label: 'reverseTxid involution' } + ) +}) + +test('correctReference selects an existing target in either byte order', async () => { + await forAll( + () => txidGen(rng), + async (txid) => { + const display = makeLevel() + await display.postsDb.put(txid, {}) + if (await correctReference(display.postsDb, txid) !== txid) return false + + const reversed = makeLevel() + await reversed.postsDb.put(txid, {}) + if (await correctReference(reversed.postsDb, reverseTxid(txid)) !== txid) return false + + const unknown = makeLevel() + const missing = txid + if (await correctReference(unknown.postsDb, missing) !== missing) return false + + return true + }, + { label: 'correctReference selection' } + ) +}) + +test('repairTxidEncoding is idempotent and conserves records', async () => { + await forAll( + () => buildFixture(), + async ({ level }) => { + const before = primaryCounts(level) + + await repairTxidEncoding(level) + const afterFirst = snapshot(level) + + await repairTxidEncoding(level) + if (snapshot(level) !== afterFirst) return false + + const after = primaryCounts(level) + return JSON.stringify(before) === JSON.stringify(after) + }, + { label: 'repairTxidEncoding idempotence' } + ) +}) + +test('repairTxidEncoding resolves every resolvable reference', async () => { + await forAll( + () => buildFixture(), + async ({ level }) => { + await repairTxidEncoding(level) + + return allResolvableOrUnknown(level.likesDb, level.postsDb, 'postTxid') && + allResolvableOrUnknown(level.postParentsDb, level.postsDb, 'parentTxid') && + allResolvableOrUnknown(level.pollOptionsDb, level.pollsDb, 'pollTxid') && + allResolvableOrUnknown(level.pollVotesDb, level.pollsDb, 'pollTxid') + }, + { label: 'repairTxidEncoding resolution' } + ) +}) + +test('repairTxidEncoding rebuilds the secondary indexes for corrected records', async () => { + await forAll( + () => buildFixture(), + async ({ level }) => { + await repairTxidEncoding(level) + + for (const [likeTxid, like] of level.likesDb.map) { + if (!level.postLikesDb.map.has(`${like.postTxid}:${likeTxid}`)) return false + } + for (const [replyTxid, reply] of level.postParentsDb.map) { + if (!level.postChildrenDb.map.has(`${reply.parentTxid}:${replyTxid}`)) return false + } + return true + }, + { label: 'repairTxidEncoding index rebuild' } + ) +}) + +test('an unknown reference is left unchanged by repair', async () => { + await forAll( + () => txidGen(rng), + async (unknown) => { + const level = makeLevel() + await level.likesDb.put('like-unknown', { postTxid: unknown, addr: 'addr' }) + + await repairTxidEncoding(level) + + return (await level.likesDb.get('like-unknown')).postTxid === unknown + }, + { label: 'repairTxidEncoding unknown reference' } + ) +}) From a2229f7dd4523e6397418e2bb8ac43800940b902 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 15:25:23 -0700 Subject: [PATCH 3/5] Review txid wire encoding: harden mutation coverage and DRY test doubles Refresh the mutate4javascript and Gherkin acceptance-mutation manifests, kill the memo-like/memo-reply boundary survivors, and extract the shared FakeDb/makeLevel double used by the txid repair unit and property tests. By architect. --- .../specs/txid-wire-encoding.feature | 4 ++ psf-memo-client/src/services/hex.js | 2 +- psf-memo-client/src/services/memo-like.js | 2 +- psf-memo-client/src/services/memo-reply.js | 2 +- psf-memo-client/test/unit/memo-like.test.js | 57 +++++++++++++++++++ psf-memo-client/test/unit/memo-reply.test.js | 12 ++++ .../specs/repair-txid-encoding.feature | 4 ++ psf-memo-db/src/lib/repair-txid-encoding.js | 4 ++ .../repair-txid-encoding.property.test.js | 43 +------------- psf-memo-db/test/support/level-double.js | 54 ++++++++++++++++++ .../unit/lib/repair-txid-encoding.unit.js | 47 +-------------- 11 files changed, 140 insertions(+), 91 deletions(-) create mode 100644 psf-memo-db/test/support/level-double.js diff --git a/psf-memo-client/specs/txid-wire-encoding.feature b/psf-memo-client/specs/txid-wire-encoding.feature index 9e5c3b7..a5734ab 100644 --- a/psf-memo-client/specs/txid-wire-encoding.feature +++ b/psf-memo-client/specs/txid-wire-encoding.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-16T22:22:12.675245296Z","feature_name":"Txid wire encoding","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/txid-wire-encoding.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Txid wire encoding - 1 a like broadcasts the post txid in little-endian wire order","scenario_hash":"42f1932ce013d74c217b8becad4e7f496e84e5236a2728dcc2418b57d67bd6e6","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T22:22:12.675245296Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Txid wire encoding - 1, Txid wire encoding - 2, Txid wire encoding - 3, Txid wire encoding - 4 # # Memo actions that reference a parent transaction embed that txid as 32 raw diff --git a/psf-memo-client/src/services/hex.js b/psf-memo-client/src/services/hex.js index 7c72cea..c18099c 100644 --- a/psf-memo-client/src/services/hex.js +++ b/psf-memo-client/src/services/hex.js @@ -48,5 +48,5 @@ function buildTxidTextPayload (txid, text, label = 'Poll txid') { module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPayload } // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-28T22:43:42.484Z","module_hash":"7f3445b7df4d792113f2736232f69faee044f71f6b4a5985224615b36909607b","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":12,"end_line":26,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/buildTxidTextPayload","name":"buildTxidTextPayload","line":30,"end_line":37,"hash":"542a47a13d30c845b7ac9a04079d7a131ece3368a604840ffef75adf97b11f7d"}]} +// {"version":1,"tested_at":"2026-09-16T22:01:27.280Z","module_hash":"454b45f3ce08c5ea6346c113db6384f7a44dddbd21e2fb0a35c41ce6701e62c8","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":12,"end_line":26,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/txidToWireBytes","name":"txidToWireBytes","line":32,"end_line":34,"hash":"ac25753fecb32c929134b6e6379cae0bfddd4aa6f7ae438a08da650450e9ea1a"},{"id":"func/buildTxidTextPayload","name":"buildTxidTextPayload","line":39,"end_line":46,"hash":"068504ea13a037adabbbfeab122fb5ec6113625a9e0b009cbbe8b46b6bebdaee"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-like.js b/psf-memo-client/src/services/memo-like.js index 55488c7..b789209 100644 --- a/psf-memo-client/src/services/memo-like.js +++ b/psf-memo-client/src/services/memo-like.js @@ -198,5 +198,5 @@ MemoLike.MAX_TIP_SATS = MAX_TIP_SATS module.exports = MemoLike // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-26T13:36:49.209Z","module_hash":"f27440db63274125b6b03bafd66222b31966670d8956b865e0bf2c695aacb54b","functions":[{"id":"func/MemoLike.constructor","name":"MemoLike.constructor","line":42,"end_line":47,"hash":"6ebbf231548843e23238988fde00cca861fd89513abba00febba964f3ef32365"},{"id":"func/MemoLike.validate","name":"MemoLike.validate","line":51,"end_line":60,"hash":"d9267f14ac52c7aa3afc07333b621c5faa751efa217bf02ae7695bb640827d44"},{"id":"func/MemoLike.validateTip","name":"MemoLike.validateTip","line":64,"end_line":74,"hash":"504b41cfa8db9754d31b0698550f9fe791d005c5b44bd30ec380202f1df11d6f"},{"id":"func/MemoLike._validateTipAmount","name":"MemoLike._validateTipAmount","line":77,"end_line":95,"hash":"98483bcf6212c2e0a2400d4da1b667b9f1c017a06ee6e4f282ccdf71cb8e397a"},{"id":"func/MemoLike.getSpendableSats","name":"MemoLike.getSpendableSats","line":99,"end_line":106,"hash":"7790154b7489758e6254cc7be00802fde237a33e0bb1440a2da33d303a2bdbe7"},{"id":"func/MemoLike.like","name":"MemoLike.like","line":112,"end_line":139,"hash":"5a748442d77446643448f1835b5e546569524af642af6be7c338e33ca4901051"},{"id":"func/MemoLike.reflect","name":"MemoLike.reflect","line":142,"end_line":145,"hash":"b159b912ba5b88468e5bfb7458385edc932850fe43948f3028e8ed9fea7269e9"},{"id":"func/MemoLike._requireTipAddress","name":"MemoLike._requireTipAddress","line":148,"end_line":154,"hash":"3b838a583b14112523e2cbe856a63896b1045a1ad91dac500e38693332b7c890"},{"id":"func/MemoLike._buildTipOutput","name":"MemoLike._buildTipOutput","line":157,"end_line":161,"hash":"e2a602c0afa9730dee67497869c4d18ed75708f6503d68fb1f0c0ba596826b5a"},{"id":"func/MemoLike._notifyFeed","name":"MemoLike._notifyFeed","line":164,"end_line":173,"hash":"bfc0e888ee106e21ee73146e92ebd3e4ce41d2ed8595a1539f823dc6c8f31d06"},{"id":"func/MemoLike._incrementPostCount","name":"MemoLike._incrementPostCount","line":176,"end_line":182,"hash":"61ae383f836b64a7aeabaf0d04e50ac9a24b87f726d5d6afac5f5585cdc6031c"}]} +// {"version":1,"tested_at":"2026-09-16T22:10:37.562Z","module_hash":"62f705760252a76903f00c4f95b1b8f8a8608ad5c257c3c4a9e8b37f2b9e6cdd","functions":[{"id":"func/MemoLike.constructor","name":"MemoLike.constructor","line":44,"end_line":50,"hash":"058496fa3f123b3876e8312ed6effcc78dd2b11fb3e02f815d552dddf5c9bc22"},{"id":"func/MemoLike.validate","name":"MemoLike.validate","line":54,"end_line":63,"hash":"d9267f14ac52c7aa3afc07333b621c5faa751efa217bf02ae7695bb640827d44"},{"id":"func/MemoLike.validateTip","name":"MemoLike.validateTip","line":67,"end_line":77,"hash":"504b41cfa8db9754d31b0698550f9fe791d005c5b44bd30ec380202f1df11d6f"},{"id":"func/MemoLike._validateTipAmount","name":"MemoLike._validateTipAmount","line":80,"end_line":98,"hash":"b0f8a9d775f83c92aec63f0c4bf5b83a035648909cc9e3e4880903ea20c6a6d7"},{"id":"func/MemoLike.getSpendableSats","name":"MemoLike.getSpendableSats","line":102,"end_line":114,"hash":"e9ba4b8f5013276261487d4d5890f27d8724a34f35878416bbbd0aaa2ef221af"},{"id":"func/MemoLike.like","name":"MemoLike.like","line":120,"end_line":147,"hash":"109c1e6dbf56a76166b17cae013fbd4a5ac79d64958d6e28003174139d015a2e"},{"id":"func/MemoLike.reflect","name":"MemoLike.reflect","line":150,"end_line":153,"hash":"b159b912ba5b88468e5bfb7458385edc932850fe43948f3028e8ed9fea7269e9"},{"id":"func/MemoLike._requireTipAddress","name":"MemoLike._requireTipAddress","line":156,"end_line":162,"hash":"3b838a583b14112523e2cbe856a63896b1045a1ad91dac500e38693332b7c890"},{"id":"func/MemoLike._buildTipOutput","name":"MemoLike._buildTipOutput","line":165,"end_line":169,"hash":"e2a602c0afa9730dee67497869c4d18ed75708f6503d68fb1f0c0ba596826b5a"},{"id":"func/MemoLike._notifyFeed","name":"MemoLike._notifyFeed","line":172,"end_line":181,"hash":"bfc0e888ee106e21ee73146e92ebd3e4ce41d2ed8595a1539f823dc6c8f31d06"},{"id":"func/MemoLike._incrementPostCount","name":"MemoLike._incrementPostCount","line":184,"end_line":190,"hash":"61ae383f836b64a7aeabaf0d04e50ac9a24b87f726d5d6afac5f5585cdc6031c"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-reply.js b/psf-memo-client/src/services/memo-reply.js index 1e7d5bc..30cbf4a 100644 --- a/psf-memo-client/src/services/memo-reply.js +++ b/psf-memo-client/src/services/memo-reply.js @@ -85,5 +85,5 @@ MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES module.exports = MemoReply // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-26T12:18:17.625Z","module_hash":"3eb068f9f90f27c5d7acf2bb5a6c517d1085bcb379123ca2d7134d4fb48a092b","functions":[{"id":"func/MemoReply.constructor","name":"MemoReply.constructor","line":36,"end_line":39,"hash":"23091c1b8f7847199bab3b54d8d81e9d8432c6da96138d72ac03fcf9426d542c"},{"id":"func/MemoReply.isTooLong","name":"MemoReply.isTooLong","line":42,"end_line":44,"hash":"2e867501d184010313ba9b27a6bb1e446df8f093514ee231b90ce77699ecbaf2"},{"id":"func/MemoReply.reply","name":"MemoReply.reply","line":48,"end_line":67,"hash":"2d7b425e350b640caf6ca821146065e9c21fe56f1e8a9d8b1f6cab5504a523c1"},{"id":"func/MemoReply.reflect","name":"MemoReply.reflect","line":70,"end_line":79,"hash":"344e1bf304a4dfd475b02824b7ddbf555da0f3ec89f73b4f6009cf0bf097fb02"},{"id":"func/buildReplyPayload","name":"buildReplyPayload","line":84,"end_line":91,"hash":"ef9ee77938593f1dbf2d168c20ea4f8dee0300f64f0fdb4f06a4ba647bb782d5"},{"id":"func/hexToBytes","name":"hexToBytes","line":94,"end_line":107,"hash":"29b401020452eabcb1b54634029d8015758b77b536be3d1ed508e9d560ac93b1"}]} +// {"version":1,"tested_at":"2026-09-16T22:14:19.213Z","module_hash":"6c515a91afe94a6be8671a1541302446bb6b1015d03ce0e278644508ebd20090","functions":[{"id":"func/MemoReply.constructor","name":"MemoReply.constructor","line":36,"end_line":39,"hash":"23091c1b8f7847199bab3b54d8d81e9d8432c6da96138d72ac03fcf9426d542c"},{"id":"func/MemoReply.isTooLong","name":"MemoReply.isTooLong","line":42,"end_line":44,"hash":"2e867501d184010313ba9b27a6bb1e446df8f093514ee231b90ce77699ecbaf2"},{"id":"func/MemoReply.reply","name":"MemoReply.reply","line":48,"end_line":67,"hash":"00fb3598d5c3bdc3c6744d1316ad552364574fed105438af4aa011347f3306ce"},{"id":"func/MemoReply.reflect","name":"MemoReply.reflect","line":70,"end_line":79,"hash":"344e1bf304a4dfd475b02824b7ddbf555da0f3ec89f73b4f6009cf0bf097fb02"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/test/unit/memo-like.test.js b/psf-memo-client/test/unit/memo-like.test.js index dd52028..57462a8 100644 --- a/psf-memo-client/test/unit/memo-like.test.js +++ b/psf-memo-client/test/unit/memo-like.test.js @@ -175,3 +175,60 @@ test('_buildTipOutput omits the output for a zero tip', () => { assert.deepEqual(memoLike._buildTipOutput(0, MY_ADDRESS), []) }) + +test('validate reports ok for a valid post txid', () => { + const memoLike = new MemoLike() + + assert.deepEqual(memoLike.validate(POST_TXID), { ok: true }) +}) + +test('validateTip reports ok when the tip exactly matches the spendable balance', () => { + const memoLike = new MemoLike() + + assert.deepEqual(memoLike.validateTip(3000, 3000), { ok: true }) +}) + +test('_validateTipAmount accepts a tip exactly at the maximum', () => { + const memoLike = new MemoLike() + + assert.equal(memoLike._validateTipAmount(MemoLike.MAX_TIP_SATS), undefined) +}) + +test('getSpendableSats treats a utxo without a value field as zero', () => { + const wallet = { utxos: [{ satoshis: 1000 }, { txid: 'no-value' }] } + const memoLike = new MemoLike({ wallet }) + + assert.equal(memoLike.getSpendableSats(), 1000) +}) + +test('_requireTipAddress requires an address for a positive tip', () => { + const memoLike = new MemoLike() + + assert.throws( + () => memoLike._requireTipAddress(1, ''), + (err) => err.code === 'like_validation' + ) +}) + +test('_requireTipAddress accepts a one-character address', () => { + const memoLike = new MemoLike() + + assert.equal(memoLike._requireTipAddress(1000, 'a'), undefined) +}) + +test('_buildTipOutput builds the output for a positive tip', () => { + const memoLike = new MemoLike() + + assert.deepEqual(memoLike._buildTipOutput(1, MY_ADDRESS), [ + { address: MY_ADDRESS, amountSat: 1 } + ]) +}) + +test('_incrementPostCount defaults a missing likeCount to zero', () => { + const feed = { posts: [{ txid: POST_TXID }] } + const memoLike = new MemoLike({ feed }) + + memoLike._incrementPostCount(POST_TXID) + + assert.equal(feed.posts[0].likeCount, 1) +}) diff --git a/psf-memo-client/test/unit/memo-reply.test.js b/psf-memo-client/test/unit/memo-reply.test.js index adc9768..9109584 100644 --- a/psf-memo-client/test/unit/memo-reply.test.js +++ b/psf-memo-client/test/unit/memo-reply.test.js @@ -44,6 +44,18 @@ test('reply broadcasts the parent txid in little-endian wire order', async () => assert.equal(buf.slice(32).toString('utf8'), 'hello memo') }) +test('isTooLong accepts a message exactly at the byte limit', () => { + const memoReply = new MemoReply() + + assert.equal(memoReply.isTooLong('a'.repeat(MemoReply.MAX_REPLY_BYTES)), false) +}) + +test('isTooLong rejects a message over the byte limit', () => { + const memoReply = new MemoReply() + + assert.equal(memoReply.isTooLong('a'.repeat(MemoReply.MAX_REPLY_BYTES + 1)), true) +}) + test('reply reflects the parent txid in display order on the thread store', async () => { const wallet = makeWallet() const added = [] diff --git a/psf-memo-db/specs/repair-txid-encoding.feature b/psf-memo-db/specs/repair-txid-encoding.feature index 9f02053..fa08c5e 100644 --- a/psf-memo-db/specs/repair-txid-encoding.feature +++ b/psf-memo-db/specs/repair-txid-encoding.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-16T22:24:03.359920055Z","feature_name":"Repair txid encoding","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/repair-txid-encoding.feature","background_hash":"102ec6cf98fcd7e4ef6107baa6284bd1b425d2a2e9716f86bbff8aa3ecf39273","implementation_hash":"unknown","scenarios":[{"index":2,"name":"Repair txid encoding - 3 the repair utility corrects reversed poll option and vote references","scenario_hash":"87dcb9d331d2293c81385b9fa3e26b445626a94f2780b50d3dfa36264d062901","mutation_count":3,"result":{"Total":3,"Killed":3,"Survived":0,"Errors":0},"tested_at":"2026-09-16T22:24:03.359920055Z"},{"index":3,"name":"Repair txid encoding - 4 the repair utility leaves correctly-encoded references unchanged","scenario_hash":"bd644eb959c765fa80d54b8d4bc2a413b87e5d125f3491cddb129f9754846e96","mutation_count":6,"result":{"Total":6,"Killed":6,"Survived":0,"Errors":0},"tested_at":"2026-09-16T22:24:03.359920055Z"},{"index":4,"name":"Repair txid encoding - 5 the repair utility leaves an unknown reference unchanged","scenario_hash":"e8fcfddce1ae48596f7ef21e6de50d7962fea6eb2a37313ce69a1e22c2a08aaa","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T22:24:03.359920055Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Repair txid encoding - 1, Repair txid encoding - 2, Repair txid encoding - 3, Repair txid encoding - 4, Repair txid encoding - 5, Repair txid encoding - 6 # # Older psf-memo-client broadcasts embedded a referenced txid in big-endian diff --git a/psf-memo-db/src/lib/repair-txid-encoding.js b/psf-memo-db/src/lib/repair-txid-encoding.js index 9da367d..506aaa0 100644 --- a/psf-memo-db/src/lib/repair-txid-encoding.js +++ b/psf-memo-db/src/lib/repair-txid-encoding.js @@ -110,3 +110,7 @@ export async function repairTxidEncoding (level) { return { likes, replies, pollOptions, pollVotes } } + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-16T22:08:39.973Z","module_hash":"503f8556da96560869090037a52d4e6f25755c56115514d69000bb766c3a34b1","functions":[{"id":"func/reverseTxid","name":"reverseTxid","line":20,"end_line":23,"hash":"34ca3f58033b0a4e34f613eca6dbb92874930d818b4258dd1fc3983dc60f5c59"},{"id":"func/hasRecord","name":"hasRecord","line":26,"end_line":34,"hash":"06580e61d91df680470d1702724aa084dad7f830a3d4be251d4bed430c893008"},{"id":"func/correctReference","name":"correctReference","line":39,"end_line":44,"hash":"d21e349238201c186d0c1216290e1c3bc4c357e42cd1bbe12273ae691f33ff32"},{"id":"func/repairStore","name":"repairStore","line":49,"end_line":70,"hash":"4a03f168d7c9ee3bbcd2dced4cca2903ef27b3d1b5616e77ff8b92302e275ab8"},{"id":"func/txidIndex","name":"txidIndex","line":74,"end_line":80,"hash":"2193de97502a9d50cef2cd9077a29a2bd141562b4a52b6f2b234c16f42e98606"},{"id":"func/repairTxidEncoding","name":"repairTxidEncoding","line":84,"end_line":112,"hash":"b1a18916b2681e83b59b1b14e1727a46d6780f47b8efdcd5b7726d2452a89467"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/test/property/repair-txid-encoding.property.test.js b/psf-memo-db/test/property/repair-txid-encoding.property.test.js index e774f30..2f5e6b4 100644 --- a/psf-memo-db/test/property/repair-txid-encoding.property.test.js +++ b/psf-memo-db/test/property/repair-txid-encoding.property.test.js @@ -22,51 +22,10 @@ import { correctReference, repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js' +import { makeLevel } from '../support/level-double.js' const rng = seededRandom(20260916) -class FakeDb { - constructor () { - this.map = new Map() - } - - async get (key) { - if (!this.map.has(key)) { - const err = new Error(`not found: ${key}`) - err.notFound = true - throw err - } - return this.map.get(key) - } - - async put (key, value) { - this.map.set(key, value) - } - - async del (key) { - this.map.delete(key) - } - - async * iterator () { - for (const [key, value] of this.map) { - yield [key, value] - } - } -} - -function makeLevel () { - return { - postsDb: new FakeDb(), - pollsDb: new FakeDb(), - likesDb: new FakeDb(), - postLikesDb: new FakeDb(), - postParentsDb: new FakeDb(), - postChildrenDb: new FakeDb(), - pollOptionsDb: new FakeDb(), - pollVotesDb: new FakeDb() - } -} - // Snapshot every store as a stable string so a second run can be compared. function snapshot (level) { const stores = {} diff --git a/psf-memo-db/test/support/level-double.js b/psf-memo-db/test/support/level-double.js new file mode 100644 index 0000000..2660dfa --- /dev/null +++ b/psf-memo-db/test/support/level-double.js @@ -0,0 +1,54 @@ +/* + In-memory LevelDB double shared by the txid repair unit and property tests. + + FakeDb implements just the get/put/del/iterator surface the repair library + uses, plus keys() for assertions. makeLevel() wires the same store names the + real psf-memo-db exposes so the repair library can be driven without opening + any real LevelDB files. +*/ + +export class FakeDb { + constructor () { + this.map = new Map() + } + + async get (key) { + if (!this.map.has(key)) { + const err = new Error(`not found: ${key}`) + err.notFound = true + throw err + } + return this.map.get(key) + } + + async put (key, value) { + this.map.set(key, value) + } + + async del (key) { + this.map.delete(key) + } + + async * iterator () { + for (const [key, value] of this.map) { + yield [key, value] + } + } + + keys () { + return [...this.map.keys()].sort() + } +} + +export function makeLevel () { + return { + postsDb: new FakeDb(), + pollsDb: new FakeDb(), + likesDb: new FakeDb(), + postLikesDb: new FakeDb(), + postParentsDb: new FakeDb(), + postChildrenDb: new FakeDb(), + pollOptionsDb: new FakeDb(), + pollVotesDb: new FakeDb() + } +} diff --git a/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js b/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js index dc30a54..7b4622e 100644 --- a/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js +++ b/psf-memo-db/test/unit/lib/repair-txid-encoding.unit.js @@ -15,6 +15,7 @@ import { correctReference, repairTxidEncoding } from '../../../src/lib/repair-txid-encoding.js' +import { makeLevel } from '../../support/level-double.js' const DISPLAY_POST = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' const REVERSED_POST = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301' @@ -22,52 +23,6 @@ const DISPLAY_POLL = '00112233445566778899aabbccddeeff00112233445566778899aabbcc const REVERSED_POLL = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100' const UNKNOWN = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' -class FakeDb { - constructor () { - this.map = new Map() - } - - async get (key) { - if (!this.map.has(key)) { - const err = new Error(`not found: ${key}`) - err.notFound = true - throw err - } - return this.map.get(key) - } - - async put (key, value) { - this.map.set(key, value) - } - - async del (key) { - this.map.delete(key) - } - - async * iterator () { - for (const [key, value] of this.map) { - yield [key, value] - } - } - - keys () { - return [...this.map.keys()].sort() - } -} - -function makeLevel () { - return { - postsDb: new FakeDb(), - likesDb: new FakeDb(), - postLikesDb: new FakeDb(), - postParentsDb: new FakeDb(), - postChildrenDb: new FakeDb(), - pollsDb: new FakeDb(), - pollOptionsDb: new FakeDb(), - pollVotesDb: new FakeDb() - } -} - function loadReversedFixture (level) { level.postsDb.put(DISPLAY_POST, { addr: 'addr-a', text: 'post', blockHeight: 1 }) level.pollsDb.put(DISPLAY_POLL, { question: 'q', pollType: 1, optionCount: 2, blockHeight: 2 }) From 292d2005ea2eccd8a32280f07e5c50f20e692794 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 15:31:38 -0700 Subject: [PATCH 4/5] Record txid-wire-encoding review and verification By architect. --- .../txid-wire-encoding-db-verification.json | 39 ++++ docs/reviews/txid-wire-encoding-summary.md | 178 ++++++++++++++++++ .../txid-wire-encoding-verification.json | 46 +++++ 3 files changed, 263 insertions(+) create mode 100644 docs/reviews/txid-wire-encoding-db-verification.json create mode 100644 docs/reviews/txid-wire-encoding-summary.md create mode 100644 docs/reviews/txid-wire-encoding-verification.json diff --git a/docs/reviews/txid-wire-encoding-db-verification.json b/docs/reviews/txid-wire-encoding-db-verification.json new file mode 100644 index 0000000..287bc53 --- /dev/null +++ b/docs/reviews/txid-wire-encoding-db-verification.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "task": "txid-wire-encoding", + "component": "psf-memo-db", + "git_sha": "a2229f7dd4523e6397418e2bb8ac43800940b902", + "branch": "swarmforge-architect", + "timestamp": "2026-09-16T22:29:57.163Z", + "commands": [ + { + "name": "unit", + "command": "npm test", + "exit": 0, + "duration_ms": 5056, + "summary": "371 passing" + }, + { + "name": "property", + "command": "npm run property", + "exit": 0, + "duration_ms": 20174, + "summary": "54 pass / 0 fail" + }, + { + "name": "acceptance", + "command": "npm run acceptance", + "exit": 0, + "duration_ms": 91309, + "summary": "all 13 acceptance suites passed" + }, + { + "name": "lint", + "command": "npm run lint", + "exit": 0, + "duration_ms": 4175, + "summary": "ok" + } + ], + "result": "pass" +} diff --git a/docs/reviews/txid-wire-encoding-summary.md b/docs/reviews/txid-wire-encoding-summary.md new file mode 100644 index 0000000..009a36f --- /dev/null +++ b/docs/reviews/txid-wire-encoding-summary.md @@ -0,0 +1,178 @@ +# txid-wire-encoding — Architect Review + +Task: `txid-wire-encoding` +Components: `psf-memo-client`, `psf-memo-db` +Base: `bf7edd5` (last merged architect review); inbound refactorer commit `a76fcee` + +## What was reviewed + +Inbound refactorer batch (priority 10), merged onto `swarmforge-architect` by +fast-forwarding to `a76fcee`. The linear chain reviewed: + +- **`bb0c9ab`** — specifier: *Spec txid wire encoding and database repair*. + Adds `psf-memo-client/specs/txid-wire-encoding.feature` (4 scenario outlines + pinning the like, reply, poll-option, and poll-vote wire order) and + `psf-memo-db/specs/repair-txid-encoding.feature` (6 scenario outlines for the + byte-reversed-reference repair utility). +- **`dedb7a1`** — coder: *Fix txid wire encoding and add txid repair utility*. + Adds `txidToWireBytes` and reverses the txid in `buildTxidTextPayload`, so + likes, replies, poll options, and poll votes now embed the referenced txid + little-endian; adds the `repair-txid-encoding` library (`correctReference`, + `repairTxidEncoding`), the `util/txid` CLI wrapper, unit tests, the DB + acceptance fixture/handlers, and decodes the wire txid in the client + acceptance handlers. +- **`a76fcee`** — refactorer: *Refactor txid wire encoding and repair library*. + Generalizes the four near-identical repair loops into `repairStore` plus a + `txidIndex` key/value adapter, replaces the reply-only `buildReplyPayload` + with the shared `buildTxidTextPayload(txid, text, label)`, and adds client + and DB property tests (round trip, involution, idempotence, conservation, + resolution, index rebuild). + +**Architect review commit: `a2229f7dd4`** — the `mutate4javascript` footer +manifests (client `hex.js`, `memo-like.js`, `memo-reply.js`; DB +`src/lib/repair-txid-encoding.js`), the soft `gherkin-mutator` +acceptance-mutation manifests on both touched feature files, the hardening +tests below, and the extracted shared test double. The summary and verification +records are committed on top, so `git diff a2229f7dd4 HEAD` touches only +`docs/`. The records' `git_sha` is `a2229f7dd4`, the commit that contains the +verified source state. + +## Architectural findings and fixes applied + +The refactorer's structure is sound and required no further boundary change. +The hardening work was mutation- and duplication-driven. + +1. **UI/Core separation.** `src/services/hex.js` is a pure leaf (byte decode, + reverse, payload assembly) with no React, DOM, wallet, or IO. `memo-like.js` + and `memo-reply.js` are core services that receive the wallet/feed/thread + behind injected adapter doubles. `src/lib/repair-txid-encoding.js` is a pure + async core that receives the LevelDB handles; only + `util/txid/repair-txid-encoding.js` opens real stores. Every piece of core + behavior is exercised without a browser, network, or database file. +2. **Dependency rule.** The client services depend inward on `hex.js`; the DB + CLI wrapper depends inward on `src/lib`. Nothing in the pure modules reaches + out to React, the router, LevelDB, or the file system. +3. **Information hiding.** The little-endian wire order now has exactly one + definition (`txidToWireBytes`), and the txid+text payload shape has one + definition (`buildTxidTextPayload`). The refactorer's `repairStore` + + `txidIndex` hide the per-store field/key/value differences behind a small + adapter, and `correctReference` hides the "keep / reverse / leave" decision. + No persistence structure leaks into callers. +4. **Minor coupling, accepted.** `buildTxidTextPayload` defaults its label to + `'Poll txid'`, a residue of its poll-only origin. It is now generic and the + reply caller passes `'Parent txid'` explicitly; the poll callers rely on the + default. A fully neutral default was not worth churning the poll call sites. +5. **Test boundaries.** The task-local `FakeDb`/`makeLevel` double was + duplicated verbatim across the new unit and property tests, so it was + extracted to `psf-memo-db/test/support/level-double.js` (a test helper, kept + out of `test/unit/**` and `test/property/*.test.js`). The util CLI wrapper is + an environmentally unsuitable adapter shell and is deliberately excluded + from the tools that run tests/coverage/mutation. +6. **Mutation hardening (see below).** Six pre-existing boundary survivors in + `memo-like.js` and one in `memo-reply.js` were killed with focused boundary + assertions rather than design changes. + +## Verification results + +### Language mutation (`mutate4javascript`, `--mutate-all` where the wrapper +detected differential under-selection, `--max-workers 8`) + +| File | Sites | Killed | Survived | Uncovered | +|------|------:|-------:|---------:|----------:| +| `psf-memo-client/src/services/hex.js` | 7 | 7 | 0 | 0 | +| `psf-memo-client/src/services/memo-like.js` | 34 | 34 | 0 | 0 | +| `psf-memo-client/src/services/memo-reply.js` | 2 | 2 | 0 | 0 | +| `psf-memo-db/src/lib/repair-txid-encoding.js` | 6 | 6 | 0 | 0 | + +The first `memo-like.js` run left 9 survivors (`true -> false` on the +`validate`/`validateTip` return values, the `validateTip` +`tipSats > spendableSats` boundary, the `_validateTipAmount` max-tip boundary, +the `getSpendableSats` `?? 0` default, the `_requireTipAddress` `<= 0` and +`.length > 0` boundaries, the `_buildTipOutput` `tipSats > 0` boundary, and the +`_incrementPostCount` `|| 0` default). `memo-reply.js` left the +`isTooLong` `> MAX_REPLY_BYTES` boundary. Each was a missing boundary/default +assertion, not a design fault; focused unit assertions were added and the +re-runs report 34/0 and 2/0. `hex.js` and the DB repair library were clean on +the first full run. + +`psf-memo-db/util/txid/repair-txid-encoding.js` is not mutated: it is the +file-system adapter shell (opens real LevelDB stores, no unit coverage by +design), and the tool that runs tests is scoped to the testable core. + +### DRY (`dry4javascript`) + +Scoped runs over the changed production files, tests, and adapters. + +- **DB.** The verbatim `FakeDb`/`makeLevel` duplication across the two new test + files was extracted to `test/support/level-double.js`; the scoped report + dropped from 72 to 70 duplicate blocks. Every remaining block is pre-existing + `acceptance/lib/handlers.js` step-handler boilerplate, except one structurally + identical but semantically distinct pair in the unit test (like/postLikes vs + reply/postChildren index-rebuild assertions), left as deliberate test + boilerplate. +- **Client.** The task-local candidates are two `makeWallet` doubles + (`test/property/poll-services.property.test.js` vs + `test/unit/memo-reply.test.js`) and one structurally identical pair of + like-rejection tests in `memo-like.test.js`. `makeWallet` appears in 28 client + test files as an established per-suite fixture convention, so extracting it + would be a broad cross-suite refactor beyond this task; both are recorded as + documented pattern-boilerplate. The remaining blocks are pre-existing + `acceptance/lib/handlers.js` boilerplate. + +### CRAP / cyclomatic complexity (`crap4javascript`) + +- **Client:** every changed function at or below CRAP 6.0 with ~100% coverage — + `MemoLike._validateTipAmount` (CC 6, 100%, 6.0), `hexToBytes` (CC 5, 5.0), + `MemoLike._incrementPostCount` (CC 5, 5.0), `MemoLike.getSpendableSats` + (CC 4, 4.0), the rest CC ≤ 3. `MemoReply.reply` is 90% covered but CRAP 2.0. +- **DB:** `repairStore` (CC 5, 100%, 5.0), `correctReference` (CC 4, 4.0), + `hasRecord` (CC 4, 4.0), `reverseTxid` (CC 3, 3.0), `repairTxidEncoding`/ + `txidIndex` (CC 1). All below the 8.0 threshold. + +### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`) + +- **`txid-wire-encoding.feature`** (client): **14 executed, 8 killed, + 6 survived, 0 errors**. All six survivors are single-character case mutations + of values that are never asserted: reply `message` (`hello memo`, + `a second one`), poll `option` (`yes`, `no`), and poll-vote `comment` + (`yes`, `I choose this`). Each scenario asserts the Memo prefix and the + referenced txid, not the carried text, so the mutated text is an intrinsic + equivalent / weak example-to-assertion link. The wire-order txid mutations + (the point of the feature) were killed. +- **`repair-txid-encoding.feature`** (DB): **21 executed, 18 killed, + 3 survived, 0 errors**. All three survivors mutate the `reversedPostTxid` + example value in scenarios 0, 1, and 5, which use it only in the negative + assertion "the postLikes store contains 0 entry whose key starts with + ``". Since the key is absent by construction, any mutated + value still yields 0 entries — a genuine negative-assertion equivalent. All + positive repair and index assertions killed their mutations. + +These survivors are specifier-side feature-quality items (weak text +assertions), not implementation gaps; no implementation change is warranted. + +### Suite status + +Canonical records, both against review commit `a2229f7dd4523e6397418e2bb8ac43800940b902`: + +- `swarmforge/scripts/verify.sh client --record + docs/reviews/txid-wire-encoding-verification.json --task txid-wire-encoding` + -> **pass (5/5)**: unit **417 pass / 0 fail**, property **80 pass / 0 fail**, + acceptance **all 30 suites passed**, lint **ok**, build **ok**. +- `swarmforge/scripts/verify.sh db --record + docs/reviews/txid-wire-encoding-db-verification.json --task txid-wire-encoding` + -> **pass (4/4)**: unit **371 passing**, property **54 pass / 0 fail**, + acceptance **all 13 suites passed**, lint **ok**. + +This is the first recent task to touch two components, so the client record +uses the canonical `-verification.json` name and the DB record uses +`-db-verification.json`; both carry the same review `git_sha`. + +## Handoffs sent + +- End-of-chain `git_handoff` to the specifier (task `txid-wire-encoding`) with + the review commit `a2229f7dd4` so it can merge `swarmforge-architect` into + `master`. +- No coder/refactorer handoff: the review is mutation hardening plus a test-double + extraction with no follow-up work for those roles. + +By architect. diff --git a/docs/reviews/txid-wire-encoding-verification.json b/docs/reviews/txid-wire-encoding-verification.json new file mode 100644 index 0000000..581d75c --- /dev/null +++ b/docs/reviews/txid-wire-encoding-verification.json @@ -0,0 +1,46 @@ +{ + "schema_version": 1, + "task": "txid-wire-encoding", + "component": "psf-memo-client", + "git_sha": "a2229f7dd4523e6397418e2bb8ac43800940b902", + "branch": "swarmforge-architect", + "timestamp": "2026-09-16T22:26:58.220Z", + "commands": [ + { + "name": "unit", + "command": "npm test", + "exit": 0, + "duration_ms": 4760, + "summary": "417 pass / 0 fail" + }, + { + "name": "property", + "command": "npm run test:property", + "exit": 0, + "duration_ms": 5651, + "summary": "80 pass / 0 fail" + }, + { + "name": "acceptance", + "command": "npm run test:acceptance", + "exit": 0, + "duration_ms": 7965, + "summary": "all 30 acceptance suites passed" + }, + { + "name": "lint", + "command": "npm run lint", + "exit": 0, + "duration_ms": 4816, + "summary": "ok" + }, + { + "name": "build", + "command": "npm run build", + "exit": 0, + "duration_ms": 41660, + "summary": "ok" + } + ], + "result": "pass" +} From c018a01cb5ea521439d53862027f824cb0e5f3e1 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 15:32:45 -0700 Subject: [PATCH 5/5] Note multi-component verification records and gherkin-mutator paths By architect. --- docs/architect-process-notes.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/architect-process-notes.md b/docs/architect-process-notes.md index 795b4cb..2747ebc 100644 --- a/docs/architect-process-notes.md +++ b/docs/architect-process-notes.md @@ -109,10 +109,27 @@ distinct from per-task verification results, which live in - **Capture the `gherkin-mutator` report with `--json` redirected to a file.** The text report (`write-text-report!`, which uses `print`) did not appear in - the captured output before the tool's `System/exit`; the JSON report +the captured output before the tool's `System/exit`; the JSON report (`--json`) did. Redirect stdout to a file and use `--json` for a reliable, parseable record of killed/survived mutations. +- **`gherkin-mutator` can run from `tmp/aps` with absolute component paths.** + The Babashka task must run where `bb.edn` defines it (`tmp/aps`), but the + runner worker resolves the job's `feature_json`/`work_dir`/`generated_dir` + paths as given. Run `cd tmp/aps && bb gherkin-mutator --feature + //specs/x.feature --work-dir //build/acceptance-mutation + --runner-worker "node //acceptance/lib/runner-worker.js" + --level soft --workers 8 --status-interval 15s --json > report.json`. The + runner command is split on whitespace, so it must be a bare `node ` + with no spaces in the path. The tool writes its manifest (and, when clean, a + `# mutation-stamp`) into the feature file; commit that tool-written change. + +- **`mutate-file.sh` works from every component dir, including the DB.** It + defaults `MUTATE4JS_BIN` to the client's installed + `node_modules/mutate4javascript`, and the tool's `npm test` baseline runs in + the current component, so the DB mutation runs use the DB suite without a + second tool install. + ## Workflow observations - **`architect-startup.sh` checks `tmp/aps` relative to the worktree, but @@ -142,3 +159,11 @@ distinct from per-task verification results, which live in - **Run per-component verification for every component a task touches** before handing off (client, db, indexer), per the monorepo rules. + +- **`verify.sh` records one component per invocation, so multi-component tasks + need multiple records.** A task touching the client and the DB cannot use a + single `-verification.json`. The first recent two-component task + (`txid-wire-encoding`) used the canonical `-verification.json` for the + primary component and `-db-verification.json` for the second; both + carry the same review `git_sha`. State the mapping explicitly in the review + summary, since the specifier's brief names only the canonical file.