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.
This commit is contained in:
Chris Troutner
2026-09-16 14:40:13 -07:00
parent bb0c9abb83
commit dedb7a1343
13 changed files with 714 additions and 15 deletions
+27 -1
View File
@@ -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')
})
@@ -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)
})
@@ -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 {
@@ -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 {
@@ -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)
})