Merge txid-wire-encoding from swarmforge-architect

By specifier.
This commit is contained in:
Chris Troutner
2026-09-16 15:32:58 -07:00
23 changed files with 1561 additions and 37 deletions
+26 -1
View File
@@ -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
<abs>/<component>/specs/x.feature --work-dir <abs>/<component>/build/acceptance-mutation
--runner-worker "node <abs>/<component>/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 <path>`
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 `<task>-verification.json`. The first recent two-component task
(`txid-wire-encoding`) used the canonical `<task>-verification.json` for the
primary component and `<task>-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.
@@ -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"
}
+178
View File
@@ -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
`<reversedPostTxid>`". 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 `<task>-verification.json` name and the DB record uses
`<task>-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.
@@ -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"
}
+5 -3
View File
@@ -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 <parameter> placeholder from the example store.
@@ -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
+14 -5
View File
@@ -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.
function buildTxidTextPayload (txid, text) {
const txidBytes = hexToBytes(txid, 32, 'Poll txid')
// given 32-byte txid in little-endian wire order followed by a UTF-8 encoded
// 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)
@@ -36,8 +45,8 @@ 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"}]}
// {"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
+3 -3
View File
@@ -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)
@@ -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
+3 -15
View File
@@ -18,11 +18,10 @@
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const { hexToBytes } = require('./hex')
const { buildTxidTextPayload } = require('./hex')
const MEMO_REPLY_PREFIX = '6d03'
const MAX_REPLY_BYTES = 184
const PARENT_TXID_BYTES = 32
class MemoReply extends MemoAction {
static config = {
@@ -58,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.
@@ -80,22 +79,11 @@ class MemoReply extends MemoAction {
}
}
// Build the raw OP_RETURN message payload for a reply.
// The protocol wire format is: <parent txid 32 bytes><reply text UTF-8 bytes>.
function buildReplyPayload (parentTxid, message) {
const parentBytes = hexToBytes(parentTxid, PARENT_TXID_BYTES, '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
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
@@ -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
},
@@ -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/
)
})
+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')
})
+234
View File
@@ -0,0 +1,234 @@
/*
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)
})
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), [])
})
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)
})
@@ -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,73 @@
/*
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('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 = []
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)
})
+130 -1
View File
@@ -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 (<limit>) and offset (<offset>)$/,
@@ -859,7 +924,7 @@ const handlers = [
},
{
name: 'postLikes contains entry',
pattern: /^the postLikes store contains (<count>) entry whose key starts with (<postTxid>) and ends with (<likeTxid>)$/,
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$/,
@@ -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
+116
View File
@@ -0,0 +1,116 @@
/*
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 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 <target txid>:<record txid>; 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 likes = await repairStore({
sourceDb: level.likesDb,
referenceField: 'postTxid',
targetDb: level.postsDb,
index: txidIndex(level.postLikesDb, (record, postTxid, likeTxid) => ({ postTxid, txid: likeTxid }))
})
const replies = await repairStore({
sourceDb: level.postParentsDb,
referenceField: 'parentTxid',
targetDb: level.postsDb,
index: txidIndex(level.postChildrenDb, (record, parentTxid) => ({ ...record, parentTxid }))
})
const pollOptions = await repairStore({
sourceDb: level.pollOptionsDb,
referenceField: 'pollTxid',
targetDb: level.pollsDb
})
const pollVotes = await repairStore({
sourceDb: level.pollVotesDb,
referenceField: 'pollTxid',
targetDb: level.pollsDb
})
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
@@ -0,0 +1,216 @@
/*
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'
import { makeLevel } from '../support/level-double.js'
const rng = seededRandom(20260916)
// 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' }
)
})
+54
View File
@@ -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()
}
}
@@ -0,0 +1,180 @@
/*
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'
import { makeLevel } from '../../support/level-double.js'
const DISPLAY_POST = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
const REVERSED_POST = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301'
const DISPLAY_POLL = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'
const REVERSED_POLL = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100'
const UNKNOWN = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
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`)
})
})
})
@@ -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()