Merge memo-multipush-encoding from swarmforge-architect

By specifier.
This commit is contained in:
Chris Troutner
2026-09-16 21:37:14 -07:00
29 changed files with 927 additions and 149 deletions
+9
View File
@@ -113,6 +113,15 @@ 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` writes an empty `scenarios` manifest when every scenario
has an intrinsic survivor.** `new-manifest` records only scenarios with
`Survived = 0` and `Errors = 0`. When all mutations are single-character case
changes of example values used consistently on both the setup and assertion
sides, every scenario survives and the committed manifest is
`"scenarios":[]` with no `# mutation-stamp`. This is the tool's expected
output (those scenarios are intentionally re-mutated next run), not a partial
write; commit it as-is and document the equivalents in the review summary.
- **`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`
@@ -0,0 +1,164 @@
# memo-multipush-encoding — Architect Review
Task: `memo-multipush-encoding`
Component: `psf-memo-client`
Base: `c018a01` (last merged architect review); inbound refactorer commit `8310b6a`
## What was reviewed
Inbound refactorer batch (priority 10), merged onto `swarmforge-architect` by
fast-forwarding to `8310b6a`. The linear chain reviewed:
- **`6eddd9c`** — specifier: *Specify multi-push encoding for Memo multi-field
actions*. Adds `psf-memo-client/specs/memo-multipush-encoding.feature` (5
scenario outlines) requiring reply, topic message, add-poll-option, and
poll-vote to broadcast the prefix plus fields as **3 separate OP_RETURN
pushes**, and create-poll as **4 pushes**.
- **`2e2db86`** — coder: *Implement multi-push encoding for Memo multi-field
actions*. Rewrites `hex.js` to return a pushes array instead of one combined
payload, adds the `memo-multipush` wallet adapter, switches the five action
services to pass field arrays, adds acceptance step handlers, and adds
unit/property tests.
- **`8310b6a`** — refactorer: *Refactor multi-push helpers and add adapter
property tests*. Shares `encodeUtf8` across `hex`/poll-create/topic-post,
drops the redundant `Uint8Array` branch in the push normalizer, renames the
txid-action builder parameter `buildPayload``buildPushes`, extracts the DB
`repairedFixture`, and adds property tests for the adapter.
**Architect review commit: `fac01730c4`** — the browser-Buffer fix and the
regression test, the `encodeScript` test-helper extraction, the acceptance
assertion de-duplication, the `buffer` dependency, and the `mutate4javascript`
and soft `gherkin-mutator` manifests. The summary and verification record are
committed on top, so `git diff fac01730c4 HEAD` touches only `docs/`. The
record's `git_sha` is `fac01730c4`, the commit that contains the verified source
state.
## Architectural findings and fixes applied
The refactorer's structure is sound: `hex.js` and `utf8.js` are pure leaves,
the five action services are core modules with injected wallet/store adapters,
and `memo-multipush.js` confines the wallet wiring to a small adapter boundary.
Two real issues were found and fixed.
1. **Browser runtime defect — the adapter read the Node global `Buffer`.** This
is the important finding. `memo-multipush.js` called `Buffer.from(...)`
directly, but the CRA 5 production bundle does **not** polyfill Node globals
and the externally loaded wallet script does not define `window.Buffer`
(verified: the built bundle had no `Buffer` assignment and the only other
`Buffer.from` came from axios behind a `typeof Blob` guard). Every real
browser reply, topic message, poll option, poll vote, and create-poll would
have thrown `Buffer is not defined` at runtime, while the Node test suite
passed. `@psf/bitcoincashjs-lib`'s `compile2` requires genuine `Buffer`
instances (`Buffer.isBuffer` + `.copy`), so a `Uint8Array` substitution is
not possible. Fix: import the browser-safe implementation
(`const { Buffer } = require('buffer')`) — the same module the bundled
bitcoin library uses — and declare `buffer` as a direct dependency so the
adapter does not depend on a dev-transitive package. Confirmed in the built
bundle: the adapter now compiles to `const{Buffer:r}=n(6382)` instead of a
free global. A regression test re-requires the module with `global.Buffer`
removed and asserts pushes still build.
2. **Mutation survivors in `attachMultiPushOpReturn`.** The first run killed 2
of 4 sites. The `!wallet || wallet.__multiPushAttached` logical mutation
survived because nothing tested a falsy wallet, and `__multiPushAttached =
true` survived because the idempotence test only counted wallet calls (which
a harmless double-wrap does not change). Focused tests now assert a falsy
wallet is a no-op and that a second attach leaves the wrapper function
identity unchanged; the file re-runs 4/4.
3. **DRY — duplicated test/acceptance helpers.** The `encodeScript` Bitcoin
script double was copied verbatim between the new unit and property tests;
extracted to `test/support/script-encoding.js` (a test helper, outside
`test/unit/**` and `test/property/*.test.js`). The three new acceptance
handlers that assert a UTF-8 push (topic/text/question) were structurally
identical; extracted `assertUtf8Push`. Scoped DRY dropped the duplicate
blocks in the touched set from 132 to 129 and removed the new-handler
duplicates.
4. **UI/Core separation, dependency rule, information hiding.** `memo-multipush`
depends on nothing; `hex`/`utf8` are pure; the action services depend inward
on those leaves; no React/DOM/router/LevelDB/wallet-global leaks. The wire
order still has one definition (`txidToWireBytes`) and the txid+text push
shape one definition (`buildTxidTextPushes`). The adapter hides the
`bchjs.Script.encode2` swap behind `attachMultiPushOpReturn`, restoring it
synchronously inside the same tick (the property tests pin the expansion and
delegation contract).
No further boundary change was required.
## Verification results
### Language mutation (`mutate4javascript`, `--mutate-all` when the wrapper
detected differential under-selection, `--max-workers 8`)
| File | Sites | Killed | Survived | Uncovered |
|------|------:|-------:|---------:|----------:|
| `src/services/memo-multipush.js` | 4 | 4 | 0 | 0 |
| `src/services/hex.js` | 5 | 5 | 0 | 0 |
| `src/services/utf8.js` | 0 | 0 | 0 | 0 |
| `src/services/memo-reply.js` | 2 | 2 | 0 | 0 |
| `src/services/memo-topic-post.js` | 7 | 7 | 0 | 0 |
| `src/services/memo-poll-create.js` | 7 | 7 | 0 | 0 |
| `src/services/memo-poll-option.js` | 0 | 0 | 0 | 0 |
| `src/services/memo-poll-vote.js` | 0 | 0 | 0 | 0 |
| `src/services/memo-txid-action.js` | 4 | 4 | 0 | 0 |
`utf8.js`, `memo-poll-option.js`, and `memo-poll-vote.js` were confirmed with
`--scan` as structural zeros (`Total mutation sites: 0`): they contain no
arithmetic/comparison/boolean/logical/`0<->1` sites, only string/array work and
base-class delegation. `async-load.js` is the browser/wallet adapter shell and
is excluded from tools that run the test suite, consistent with the standing
precedent for adapter shells.
### DRY (`dry4javascript`, scoped to the touched production files, tests, and
adapters)
129 duplicate blocks remain in the scoped set, all pre-existing pattern
boilerplate: `acceptance/lib/handlers.js` step-handler repetition (192 of the
line references), the established per-suite `makeWallet` test double (also
present across ~28 client test files), and parallel poll/topic/reply unit-test
bodies that exercise the parallel `MemoTxidAction` subclasses on purpose. The
only two task-local duplications (`encodeScript`, the three UTF-8 push
assertions) were extracted. The production-service duplicates
(`memo-poll-option`/`memo-poll-vote` config blocks, the two 2-field
constructors) are intentional parallel-config declarations and were left as-is
to preserve their per-action clarity.
### CRAP / cyclomatic complexity (`crap4javascript`)
All changed functions at or below CRAP 5.0 with ~100% coverage:
`hexToBytes` (CC 5, 100%, 5.0), `MemoPollCreate.create` (CC 4, 4.0),
`attachMultiPushOpReturn`/`broadcastMultiPush` (CC 3, 3.0), and the rest CC ≤ 3.
`MemoReply.reply` is 90% covered but CRAP 2.0. Well below the 8.0 threshold.
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
**`memo-multipush-encoding.feature`: 20 executed, 8 killed, 12 survived, 0
errors.** Every survivor is a single-character case mutation of a `text`,
`topic`, or `question` example value. Those values are used consistently on the
setup and assertion sides of their scenario (`...with the text "<text>"` /
`...is the UTF-8 text "<text>"`), so the mutant is an intrinsic equivalent: the
push bytes match whatever the example says. The point of the feature — the
separate-push shape (count), the little-endian wire txid, the poll type, and the
option count — is killed (all 8 non-text mutations, including the `option_count`
and txid mutations). No implementation change is warranted. The tool left its
manifest (empty `scenarios`, because every scenario has equivalents) in the
feature file; it is committed as tool-written.
### Suite status
Canonical record against review commit
`fac01730c4beaf5b412302f919e47871778f4d08`:
- `swarmforge/scripts/verify.sh client --record
docs/reviews/memo-multipush-encoding-verification.json --task
memo-multipush-encoding` -> **pass (5/5)**: unit **425 pass / 0 fail**,
property **85 pass / 0 fail**, acceptance **all 31 suites passed**, lint
**ok**, build **ok**.
## Handoffs sent
- End-of-chain `git_handoff` to the specifier (task `memo-multipush-encoding`)
with the review commit `fac01730c4` so it can merge `swarmforge-architect`
into `master`.
- No coder/refactorer handoff: the review is a runtime fix, mutation hardening,
and local DRY with no follow-up work for those roles.
By architect.
@@ -0,0 +1,46 @@
{
"schema_version": 1,
"task": "memo-multipush-encoding",
"component": "psf-memo-client",
"git_sha": "fac01730c4beaf5b412302f919e47871778f4d08",
"branch": "swarmforge-architect",
"timestamp": "2026-09-17T04:33:07.792Z",
"commands": [
{
"name": "unit",
"command": "npm test",
"exit": 0,
"duration_ms": 8897,
"summary": "425 pass / 0 fail"
},
{
"name": "property",
"command": "npm run test:property",
"exit": 0,
"duration_ms": 10258,
"summary": "85 pass / 0 fail"
},
{
"name": "acceptance",
"command": "npm run test:acceptance",
"exit": 0,
"duration_ms": 14315,
"summary": "all 31 acceptance suites passed"
},
{
"name": "lint",
"command": "npm run lint",
"exit": 0,
"duration_ms": 2278,
"summary": "ok"
},
{
"name": "build",
"command": "npm run build",
"exit": 0,
"duration_ms": 60886,
"summary": "ok"
}
],
"result": "pass"
}
+124 -7
View File
@@ -58,6 +58,7 @@ const { renderPostOptions } = require('./render-post-options')
const { renderLikeResult } = require('./render-like-result')
const PostOptions = require('../../src/services/post-options')
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
const { toPushBuffer } = require('../../src/services/memo-multipush')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
@@ -102,12 +103,19 @@ function makeWallet (address) {
return this.utxos
},
sendOpReturn: async function (msg, prefix, bchOutput = []) {
// Normalize binary payloads to Buffer so assertions can safely use
// toString('hex'), while preserving string payloads unchanged.
const storedMsg = (msg instanceof Uint8Array || ArrayBuffer.isView(msg))
? Buffer.from(msg)
: msg
this.broadcasts.push({ msg: storedMsg, prefix, bchOutput })
// Multi-field actions pass an array of field pushes. Expose each push
// separately for the multi-push assertions, while keeping `msg` as the
// concatenated payload so older combined-payload assertions still work.
const isMulti = Array.isArray(msg)
const fields = isMulti ? msg : [msg]
const fieldBuffers = fields.map((field) => toPushBuffer(field))
const storedMsg = isMulti
? Buffer.concat(fieldBuffers)
: ((msg instanceof Uint8Array || ArrayBuffer.isView(msg))
? Buffer.from(msg)
: msg)
const pushes = [Buffer.from(prefix, 'hex'), ...fieldBuffers]
this.broadcasts.push({ msg: storedMsg, pushes, prefix, bchOutput })
if (this.failWith) throw new Error(this.failWith)
return 'aa'.repeat(32)
}
@@ -559,6 +567,38 @@ function decodeLikeTxid (raw) {
return Buffer.from(raw).reverse().toString('hex')
}
// Return the most recent wallet broadcast, or fail when none was sent.
function lastBroadcast (world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
return broadcasts[broadcasts.length - 1]
}
// Return the push at the given zero-based index from the most recent
// broadcast. Index 0 is the action prefix; later indexes are the fields.
function broadcastPush (world, index) {
const last = lastBroadcast(world)
if (!Array.isArray(last.pushes) || last.pushes.length <= index) {
throw new Error(`Broadcast does not have a push at index ${index}.`)
}
return Buffer.from(last.pushes[index])
}
// Reverse a display txid into little-endian wire hex.
function txidWireHex (txid) {
return Buffer.from(txid, 'hex').reverse().toString('hex')
}
// Assert that the most recent broadcast's push at `index` decodes to the
// expected UTF-8 text. Shared by the topic/text/question push assertions.
function assertUtf8Push (world, index, expected, label) {
const push = broadcastPush(world, index)
const actual = push.toString('utf8')
if (actual !== expected) {
throw new Error(`${label} push "${actual}" did not match "${expected}".`)
}
}
// Resolve a literal value or a <parameter> placeholder from the example store.
function resolveParam (value, example) {
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim())
@@ -1046,6 +1086,82 @@ const handlers = [
}
}
},
{
name: 'wallet broadcasts multi-push OP_RETURN with prefix',
pattern: /^the wallet broadcasts an OP_RETURN with the Memo (reply|topic-message|add-poll-option|poll-vote|create-poll) prefix and (\d+) pushes$/,
run (m, example, world) {
const prefix = {
reply: MEMO_REPLY_PREFIX,
'topic-message': MEMO_TOPIC_MESSAGE_PREFIX,
'add-poll-option': MEMO_ADD_POLL_OPTION_PREFIX,
'poll-vote': MEMO_POLL_VOTE_PREFIX,
'create-poll': MEMO_CREATE_POLL_PREFIX
}[m[1]]
const expectedPushes = parseInt(m[2], 10)
const last = lastBroadcast(world)
if (last.prefix !== prefix) {
throw new Error(`Expected Memo ${m[1]} prefix ${prefix}, got "${last.prefix}".`)
}
const actual = Array.isArray(last.pushes) ? last.pushes.length : 0
if (actual !== expectedPushes) {
throw new Error(`Expected ${expectedPushes} OP_RETURN pushes, got ${actual}.`)
}
}
},
{
name: 'second broadcast push is referenced txid in wire order',
pattern: /^the second broadcast push is the referenced txid (.+) in little-endian wire order$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const push = broadcastPush(world, 1)
if (push.toString('hex') !== txidWireHex(txid)) {
throw new Error(`Second push did not match the wire txid ${txid}.`)
}
}
},
{
name: 'second broadcast push is UTF-8 topic',
pattern: /^the second broadcast push is the UTF-8 topic "(.+)"$/,
run (m, example, world) {
assertUtf8Push(world, 1, resolveText(m[1], example), 'Second')
}
},
{
name: 'second broadcast push is poll type',
pattern: /^the second broadcast push is the poll type (\d+)$/,
run (m, example, world) {
const expected = parseInt(m[1], 10)
const push = broadcastPush(world, 1)
if (push.length !== 1 || push[0] !== expected) {
throw new Error(`Second push was not the poll type ${expected}.`)
}
}
},
{
name: 'third broadcast push is UTF-8 text',
pattern: /^the third broadcast push is the UTF-8 text "(.+)"$/,
run (m, example, world) {
assertUtf8Push(world, 2, resolveText(m[1], example), 'Third')
}
},
{
name: 'third broadcast push is option count',
pattern: /^the third broadcast push is the option count (.+)$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
const push = broadcastPush(world, 2)
if (push.length !== 1 || push[0] !== expected) {
throw new Error(`Third push was not the option count ${expected}.`)
}
}
},
{
name: 'fourth broadcast push is UTF-8 question',
pattern: /^the fourth broadcast push is the UTF-8 question "(.+)"$/,
run (m, example, world) {
assertUtf8Push(world, 3, resolveText(m[1], example), 'Fourth')
}
},
{
name: 'thread shows new reply from my address',
pattern: /^the thread shows a new reply from my address with the text "<([A-Za-z0-9_]+)>"$/,
@@ -2223,7 +2339,8 @@ const handlers = [
throw new Error(`Expected Memo topic-message prefix ${MEMO_TOPIC_MESSAGE_PREFIX}, got "${last.prefix}".`)
}
const expectedPayload = room + world.topicPostPage.input
if (last.msg !== expectedPayload) {
const actualPayload = Buffer.isBuffer(last.msg) ? last.msg.toString('utf8') : last.msg
if (actualPayload !== expectedPayload) {
throw new Error(`Broadcast topic-message payload did not match ${room} + input.`)
}
}
+3 -3
View File
@@ -17,6 +17,7 @@
"bch-message-lib": "2.2.1",
"bch-token-sweep": "2.2.1",
"bootstrap": "5.2.0",
"buffer": "^6.0.3",
"qrcode.react": "4.2.0",
"query-string": "7.1.1",
"react": "19.0.0",
@@ -6160,7 +6161,6 @@
},
"node_modules/base64-js": {
"version": "1.5.1",
"dev": true,
"funding": [
{
"type": "github",
@@ -6637,7 +6637,8 @@
},
"node_modules/buffer": {
"version": "6.0.3",
"dev": true,
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
@@ -11703,7 +11704,6 @@
},
"node_modules/ieee754": {
"version": "1.2.1",
"dev": true,
"funding": [
{
"type": "github",
+1
View File
@@ -11,6 +11,7 @@
"bch-message-lib": "2.2.1",
"bch-token-sweep": "2.2.1",
"bootstrap": "5.2.0",
"buffer": "^6.0.3",
"qrcode.react": "4.2.0",
"query-string": "7.1.1",
"react": "19.0.0",
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-17T04:24:27.366008662Z","feature_name":"Memo multi-push encoding","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/memo-multipush-encoding.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[]}
# acceptance-mutation-manifest-end
# Scenarios: Memo multi-push encoding - 1, Memo multi-push encoding - 2, Memo multi-push encoding - 3, Memo multi-push encoding - 4, Memo multi-push encoding - 5
#
# Memo actions that carry more than one field encode each protocol field as
@@ -6,6 +6,7 @@
import axios from 'axios'
// Local libraries
import GistServers from './gist-servers'
import memoMultipush from './memo-multipush'
class AsyncLoad {
constructor () {
@@ -66,6 +67,10 @@ class AsyncLoad {
this.wallet = wallet
// Teach the wallet to broadcast multi-field Memo actions as separate
// OP_RETURN pushes.
memoMultipush.attachMultiPushOpReturn(wallet)
return wallet
} catch (error) {
console.error('Error initializing wallet: ', error)
@@ -240,6 +245,10 @@ class AsyncLoad {
this.wallet = wallet
// Teach the wallet to broadcast multi-field Memo actions as separate
// OP_RETURN pushes.
memoMultipush.attachMultiPushOpReturn(wallet)
return wallet
} catch (error) {
console.error('Error initStarterWallet: ', error)
+10 -11
View File
@@ -7,6 +7,8 @@
testable conversion helper.
*/
const { encodeUtf8 } = require('./utf8')
// Decode a hex string into a Uint8Array of the requested byte length.
// The label parameter customizes error messages for the caller's context.
function hexToBytes (hex, byteLength = 32, label = 'Value') {
@@ -33,20 +35,17 @@ 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 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') {
// Build the separate OP_RETURN pushes for a txid-referencing Memo action:
// the referenced txid in little-endian wire order and the UTF-8 encoded value,
// each as its own push. The label customizes the invalid-txid error message.
function buildTxidTextPushes (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)
raw.set(textBytes, txidBytes.length)
return raw
const textBytes = encodeUtf8(text)
return [txidBytes, textBytes]
}
module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPayload }
module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPushes }
// mutate4javascript-manifest-begin
// {"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"}]}
// {"version":1,"tested_at":"2026-09-17T04:10:11.935Z","module_hash":"e3a608fad7d52d78c2c94a873161d6085c9afc2326a07fa04eb8a67815ed362a","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":14,"end_line":28,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/txidToWireBytes","name":"txidToWireBytes","line":34,"end_line":36,"hash":"ac25753fecb32c929134b6e6379cae0bfddd4aa6f7ae438a08da650450e9ea1a"},{"id":"func/buildTxidTextPushes","name":"buildTxidTextPushes","line":41,"end_line":45,"hash":"815887680e60a7ed823b15fe6cc25b16f931e4390e37dd89aee409cf55836fd0"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,99 @@
/*
Multi-push OP_RETURN adapter for Memo actions that carry several fields.
Memo multi-field actions (reply, topic message, add-poll-option, poll-vote,
and create-poll) must encode each protocol field as its own OP_RETURN script
push: OP_RETURN <prefix> <field> <field> ... . minimal-slp-wallet's
sendOpReturn(msg, prefix, bchOutput) pushes its msg as a single push, so this
adapter wraps a wallet's sendOpReturn: an array first argument is broadcast as
separate pushes, while every existing single-field call is delegated to the
original wallet method unchanged.
The adapter reuses the wallet's own OP_RETURN transaction builder for fee
selection, change, signing, and broadcast. Only the OP_RETURN script it
composes is expanded. The pure push-normalization helpers are unit tested;
the wallet wiring is the small environmentally unsuitable boundary.
*/
// Import the browser-compatible Buffer implementation. `Buffer` is a Node
// global, but the CRA production bundle does not polyfill Node globals and the
// wallet script does not define window.Buffer, so a bare global would throw in
// the browser. The npm buffer package is the same implementation the bundled
// bitcoincashjs-lib uses, so its Buffer.isBuffer accepts these pushes.
const { Buffer } = require('buffer')
// Normalize one push value (a UTF-8 string or byte array) to a Buffer.
function toPushBuffer (value) {
if (typeof value === 'string') return Buffer.from(value, 'utf8')
return Buffer.from(value)
}
// Build the ordered OP_RETURN pushes: the action prefix bytes first, then each
// field as its own push.
function buildPushes (prefix, fields) {
return [Buffer.from(prefix, 'hex'), ...fields.map(toPushBuffer)]
}
// Broadcast fields as separate OP_RETURN pushes using the wallet's own
// transaction builder. The wallet must expose minimal-slp-wallet's
// opReturn.createTransaction and opReturn.ar.sendTx.
async function broadcastMultiPush (wallet, fields, prefix, bchOutput = []) {
const opReturn = wallet.opReturn
if (!opReturn || typeof opReturn.createTransaction !== 'function') {
throw new Error('Wallet does not support multi-push OP_RETURN broadcasts.')
}
await wallet.walletInfoPromise
const bchjs = opReturn.bchjs
const originalEncode2 = bchjs.Script.encode2
const pushes = buildPushes(prefix, fields)
// createTransaction composes [OP_RETURN, prefix, msg] and calls encode2
// synchronously before any await. Swap in the multi-push script for that one
// call, then restore immediately so concurrent wallet work is unaffected.
bchjs.Script.encode2 = function (script) {
bchjs.Script.encode2 = originalEncode2
return originalEncode2.call(this, [script[0], ...pushes])
}
try {
const { hex } = await opReturn.createTransaction(
wallet.walletInfo,
wallet.utxos.utxoStore.bchUtxos,
'',
prefix,
bchOutput,
wallet.fee
)
return await opReturn.ar.sendTx(hex)
} finally {
bchjs.Script.encode2 = originalEncode2
}
}
// Wrap a wallet's sendOpReturn so an array first argument broadcasts each
// element as its own OP_RETURN push. Single-field calls delegate unchanged.
function attachMultiPushOpReturn (wallet) {
if (!wallet || wallet.__multiPushAttached) return wallet
const original = wallet.sendOpReturn.bind(wallet)
wallet.sendOpReturn = function (msgOrFields, prefix, bchOutput = []) {
if (Array.isArray(msgOrFields)) {
return broadcastMultiPush(wallet, msgOrFields, prefix, bchOutput)
}
return original(msgOrFields, prefix, bchOutput)
}
wallet.__multiPushAttached = true
return wallet
}
module.exports = {
toPushBuffer,
buildPushes,
broadcastMultiPush,
attachMultiPushOpReturn
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-17T04:08:42.622Z","module_hash":"9bce63e69187b0a42ba210437ea14a32fdd7f53ccfd905264375bba997a3a4c3","functions":[{"id":"func/toPushBuffer","name":"toPushBuffer","line":26,"end_line":29,"hash":"0ff6744e881d65ce60d6456df450f5f0f26a39492d387b2b33aefec8c12e2e81"},{"id":"func/buildPushes","name":"buildPushes","line":33,"end_line":35,"hash":"46961294949a914dbab5064b1b2fcf5c3ba59c2e438b2a986275d3dfed40ea71"},{"id":"func/broadcastMultiPush","name":"broadcastMultiPush","line":40,"end_line":73,"hash":"ee5022606ea152763159d7fd9753c3849b723c5a8ab1cd1cb399feb4248479d6"},{"id":"func/attachMultiPushOpReturn","name":"attachMultiPushOpReturn","line":77,"end_line":88,"hash":"ef81a1526f376fc1ad500d72dafdd684d78ad8052202e938a39654bb182e8eeb"}]}
// mutate4javascript-manifest-end
@@ -17,7 +17,7 @@
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const { byteLength, encodeUtf8 } = require('./utf8')
const MEMO_CREATE_POLL_PREFIX = '6d10'
const MAX_QUESTION_BYTES = 209
@@ -62,8 +62,8 @@ class MemoPollCreate extends MemoAction {
await this.wallet.getUtxos()
const raw = buildCreatePollPayload(question, this.pollType, count)
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
const pushes = buildCreatePollPushes(question, this.pollType, count)
const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
this.reflect(txid, question, count)
@@ -84,15 +84,15 @@ class MemoPollCreate extends MemoAction {
}
}
// Build the raw OP_RETURN message payload for a create-poll action.
// The protocol wire format is: <poll_type 1 byte><option_count 1 byte><question UTF-8 bytes>.
function buildCreatePollPayload (question, pollType, optionCount) {
const textBytes = new TextEncoder().encode(question)
const raw = new Uint8Array(2 + textBytes.length)
raw[0] = pollType & 0xff
raw[1] = optionCount & 0xff
raw.set(textBytes, 2)
return raw
// Build the separate OP_RETURN pushes for a create-poll action: the poll type
// byte, the option count byte, and the UTF-8 question, each as its own push.
function buildCreatePollPushes (question, pollType, optionCount) {
const textBytes = encodeUtf8(question)
return [
Uint8Array.from([pollType & 0xff]),
Uint8Array.from([optionCount & 0xff]),
textBytes
]
}
MemoPollCreate.MEMO_CREATE_POLL_PREFIX = MEMO_CREATE_POLL_PREFIX
@@ -102,5 +102,5 @@ MemoPollCreate.DEFAULT_POLL_TYPE = DEFAULT_POLL_TYPE
module.exports = MemoPollCreate
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:45:49.496Z","module_hash":"3455852bee199b40530bbbe787f85429a7a15bf0116989231ac06f9ca9eadf36","functions":[{"id":"func/MemoPollCreate.constructor","name":"MemoPollCreate.constructor","line":36,"end_line":40,"hash":"9404cf1f0df5cc8756e0ea24abab27ade844fa461f84818162faac807b838dd1"},{"id":"func/MemoPollCreate.isTooLong","name":"MemoPollCreate.isTooLong","line":43,"end_line":45,"hash":"ca9087a454f1fba64ac35738a037372ade36ba6d4c2270d2025adc00b860412c"},{"id":"func/MemoPollCreate.create","name":"MemoPollCreate.create","line":48,"end_line":71,"hash":"b5e769b07f3795a5db0d4202106f4b25b5a0b69e30e70491a7a9c2ac52085fce"},{"id":"func/MemoPollCreate.reflect","name":"MemoPollCreate.reflect","line":74,"end_line":84,"hash":"ec559b778b279c1efb46bdb2440a0c197dae242052d7c1ebf5331078258b719f"},{"id":"func/buildCreatePollPayload","name":"buildCreatePollPayload","line":89,"end_line":96,"hash":"f5abdaad00f9e3c857c65766a981d6971e1bc7c64e0200cbb4105ab798bd94da"}]}
// {"version":1,"tested_at":"2026-09-17T04:15:33.060Z","module_hash":"f768a640a3cf8aff7dd9836bcbed38ca2d2fa6892cca97f8d078f4ad5d9d5287","functions":[{"id":"func/MemoPollCreate.constructor","name":"MemoPollCreate.constructor","line":36,"end_line":40,"hash":"9404cf1f0df5cc8756e0ea24abab27ade844fa461f84818162faac807b838dd1"},{"id":"func/MemoPollCreate.isTooLong","name":"MemoPollCreate.isTooLong","line":43,"end_line":45,"hash":"ca9087a454f1fba64ac35738a037372ade36ba6d4c2270d2025adc00b860412c"},{"id":"func/MemoPollCreate.create","name":"MemoPollCreate.create","line":48,"end_line":71,"hash":"dd0f77edcf6d20b5273c4949a0b7b52f7b60686556b33491139077bbb6866120"},{"id":"func/MemoPollCreate.reflect","name":"MemoPollCreate.reflect","line":74,"end_line":84,"hash":"ec559b778b279c1efb46bdb2440a0c197dae242052d7c1ebf5331078258b719f"},{"id":"func/buildCreatePollPushes","name":"buildCreatePollPushes","line":89,"end_line":96,"hash":"54b38bb7fba8a90968c93441588aa42754d2b961d75778daf8b764a017030334"}]}
// mutate4javascript-manifest-end
@@ -15,7 +15,7 @@
*/
const MemoTxidAction = require('./memo-txid-action')
const { buildTxidTextPayload } = require('./hex')
const { buildTxidTextPushes } = require('./hex')
const MEMO_ADD_POLL_OPTION_PREFIX = '6d13'
const MAX_OPTION_BYTES = 184
@@ -35,7 +35,7 @@ class MemoPollOption extends MemoTxidAction {
// Compose and broadcast a Memo add-poll-option action.
add (option) {
return this.broadcastTxid(option, buildTxidTextPayload)
return this.broadcastTxid(option, buildTxidTextPushes)
}
}
@@ -45,5 +45,5 @@ MemoPollOption.MAX_OPTION_BYTES = MAX_OPTION_BYTES
module.exports = MemoPollOption
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:38:02.285Z","module_hash":"f8432d9ccb0073f5cce9f96415fdfb033876f498a7d80aadaca1ed96d89d4ce3","functions":[{"id":"func/MemoPollOption.add","name":"MemoPollOption.add","line":37,"end_line":39,"hash":"e0343ddce138d48e3fec2c5c0169e9d99f374835cc95d381383a254297d7f1dc"}]}
// {"version":1,"tested_at":"2026-09-17T04:16:42.577Z","module_hash":"4bcfeb64bcefaafb3d61272db2623e832b65930f966f5494bebfd417b29dc833","functions":[{"id":"func/MemoPollOption.add","name":"MemoPollOption.add","line":37,"end_line":39,"hash":"d8bdf2dda07743c5d6927b7a6ef69715f033aefe2e77b5ca6923c2afe3772088"}]}
// mutate4javascript-manifest-end
@@ -15,7 +15,7 @@
*/
const MemoTxidAction = require('./memo-txid-action')
const { buildTxidTextPayload } = require('./hex')
const { buildTxidTextPushes } = require('./hex')
const MEMO_POLL_VOTE_PREFIX = '6d14'
const MAX_COMMENT_BYTES = 184
@@ -35,7 +35,7 @@ class MemoPollVote extends MemoTxidAction {
// Compose and broadcast a Memo poll-vote action.
vote (comment) {
return this.broadcastTxid(comment, buildTxidTextPayload)
return this.broadcastTxid(comment, buildTxidTextPushes)
}
}
@@ -45,5 +45,5 @@ MemoPollVote.MAX_COMMENT_BYTES = MAX_COMMENT_BYTES
module.exports = MemoPollVote
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:38:23.086Z","module_hash":"c60989293dede6eeaf8edd168d00745e7299620b80874f87680337cc2f4d36fc","functions":[{"id":"func/MemoPollVote.vote","name":"MemoPollVote.vote","line":37,"end_line":39,"hash":"c6c194dfaf6c92f5cafcf86772bd295d038536ea20079931e4144eb5141c7101"}]}
// {"version":1,"tested_at":"2026-09-17T04:17:10.193Z","module_hash":"e03a12dfad981e8a9ce4d4af763f0c57bd7a0f70a8bf22adc4b83d539b4e4725","functions":[{"id":"func/MemoPollVote.vote","name":"MemoPollVote.vote","line":37,"end_line":39,"hash":"e7ec8ae8f66c762cd7bb9fd9bb3001fe2a1b63843933d57372d6fd6f6a87b3dc"}]}
// mutate4javascript-manifest-end
+5 -5
View File
@@ -18,7 +18,7 @@
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const { buildTxidTextPayload } = require('./hex')
const { buildTxidTextPushes } = require('./hex')
const MEMO_REPLY_PREFIX = '6d03'
const MAX_REPLY_BYTES = 184
@@ -56,9 +56,9 @@ class MemoReply extends MemoAction {
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
await this.wallet.getUtxos()
// Build the raw payload: parent txid bytes followed by UTF-8 message bytes.
const raw = buildTxidTextPayload(parentTxid, message, 'Parent txid')
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
// Build the separate pushes: parent txid bytes, then UTF-8 message bytes.
const pushes = buildTxidTextPushes(parentTxid, message, 'Parent txid')
const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
// Reflect the result on the injected thread once broadcast succeeds.
this.reflect(txid, message, parentTxid)
@@ -85,5 +85,5 @@ MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES
module.exports = MemoReply
// mutate4javascript-manifest-begin
// {"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"}]}
// {"version":1,"tested_at":"2026-09-17T04:12:17.098Z","module_hash":"53a16ce9198c0c94446ca0f89463072d83a9646de676708a1b9061086dbac40c","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":"b8dc4228aefdae7a9e29273a7cef7d92177af9ba6d19d318d6455bf4d202fcc6"},{"id":"func/MemoReply.reflect","name":"MemoReply.reflect","line":70,"end_line":79,"hash":"344e1bf304a4dfd475b02824b7ddbf555da0f3ec89f73b4f6009cf0bf097fb02"}]}
// mutate4javascript-manifest-end
@@ -16,7 +16,7 @@
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const { byteLength, encodeUtf8 } = require('./utf8')
const MEMO_TOPIC_MESSAGE_PREFIX = '6d0c'
const MAX_TOPIC_MESSAGE_BYTES = 214
@@ -58,8 +58,11 @@ class MemoTopicPost extends MemoAction {
await this.wallet.getUtxos()
const payload = this.room + message
const txid = await this.wallet.sendOpReturn(payload, this.prefix)
const pushes = [
encodeUtf8(this.room),
encodeUtf8(message)
]
const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
this.reflect(txid, message)
@@ -85,5 +88,5 @@ MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES = MAX_TOPIC_MESSAGE_BYTES
module.exports = MemoTopicPost
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T18:18:11.190Z","module_hash":"c73b1395983accf085921554a84c8ef9ad8014ba8d8ddfa794e051ae4a487f04","functions":[{"id":"func/MemoTopicPost.constructor","name":"MemoTopicPost.constructor","line":34,"end_line":38,"hash":"34483e7579cbde3babf17cc43f64bc6535537b6391169a75cb51025029283289"},{"id":"func/MemoTopicPost.isTooLong","name":"MemoTopicPost.isTooLong","line":42,"end_line":44,"hash":"a8d16816cf0aeda1be759f796aee75e9cc5d89ccbcc288b5d2551917121be27a"},{"id":"func/MemoTopicPost.remainingBytes","name":"MemoTopicPost.remainingBytes","line":46,"end_line":48,"hash":"11a07f6539fa6b74feabfdd258f83ede4556213951aa85266a959871ff616df1"},{"id":"func/MemoTopicPost.post","name":"MemoTopicPost.post","line":51,"end_line":67,"hash":"dec3508e4969efd3edfefbc0752ca4608bac9521fbda62cf2ce5d7bf0a5696c7"},{"id":"func/MemoTopicPost.reflect","name":"MemoTopicPost.reflect","line":70,"end_line":79,"hash":"8a136fe98fa6752b2247b9bc0064b9860cfa34e518abaa33e50e95833120d529"}]}
// {"version":1,"tested_at":"2026-09-17T04:13:34.090Z","module_hash":"c718cc342a8316ce9369d79969492e7de83e2c8b9d881c2f3d0324ac92cdf0ff","functions":[{"id":"func/MemoTopicPost.constructor","name":"MemoTopicPost.constructor","line":34,"end_line":38,"hash":"34483e7579cbde3babf17cc43f64bc6535537b6391169a75cb51025029283289"},{"id":"func/MemoTopicPost.isTooLong","name":"MemoTopicPost.isTooLong","line":42,"end_line":44,"hash":"a8d16816cf0aeda1be759f796aee75e9cc5d89ccbcc288b5d2551917121be27a"},{"id":"func/MemoTopicPost.remainingBytes","name":"MemoTopicPost.remainingBytes","line":46,"end_line":48,"hash":"11a07f6539fa6b74feabfdd258f83ede4556213951aa85266a959871ff616df1"},{"id":"func/MemoTopicPost.post","name":"MemoTopicPost.post","line":51,"end_line":70,"hash":"626b580f6c5192b03254561430a28b04decf8d089fdb12542bea8a168803e945"},{"id":"func/MemoTopicPost.reflect","name":"MemoTopicPost.reflect","line":73,"end_line":82,"hash":"8a136fe98fa6752b2247b9bc0064b9860cfa34e518abaa33e50e95833120d529"}]}
// mutate4javascript-manifest-end
@@ -22,8 +22,9 @@ class MemoTxidAction extends MemoAction {
}
// Compose and broadcast an action that embeds this.pollTxid plus the given
// value through the supplied buildPayload(pollTxid, value) function.
async broadcastTxid (value, buildPayload) {
// value. buildPushes(pollTxid, value) returns the ordered OP_RETURN field
// pushes for the action.
async broadcastTxid (value, buildPushes) {
const check = this.validate(value)
this._throwIfInvalid(check)
@@ -39,8 +40,8 @@ class MemoTxidAction extends MemoAction {
await this.wallet.getUtxos()
const raw = buildPayload(this.pollTxid, value)
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
const fields = buildPushes(this.pollTxid, value)
const txid = await this.wallet.sendOpReturn(fields, this.prefix)
this.reflect(txid, value)
@@ -72,5 +73,5 @@ class MemoTxidAction extends MemoAction {
module.exports = MemoTxidAction
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:37:21.781Z","module_hash":"b43e695aee6198c465b2cdbb0fbb405fccb0aadb573f56c3d3249de3920c1939","functions":[{"id":"func/MemoTxidAction.constructor","name":"MemoTxidAction.constructor","line":18,"end_line":22,"hash":"399be38855508b45b6a07e00361d4fb36186df3fdac5e2a68e004f7dcbdd353c"},{"id":"func/MemoTxidAction.broadcastTxid","name":"MemoTxidAction.broadcastTxid","line":26,"end_line":48,"hash":"395ea932dd691049556a66a3d4c674778818b8b53152c0ae7088d960524fc400"},{"id":"func/MemoTxidAction.reflect","name":"MemoTxidAction.reflect","line":53,"end_line":63,"hash":"7d61638162b7142298e021b02cbdeaca359f33503b3b84a308498f0479c80c5c"},{"id":"func/MemoTxidAction.isTooLong","name":"MemoTxidAction.isTooLong","line":67,"end_line":69,"hash":"7eeb0b9dcb35530de412b0aa0ade6b267e2fe22f5e49aa0f3ecf5b9124022a3b"}]}
// {"version":1,"tested_at":"2026-09-17T04:18:05.479Z","module_hash":"d0bda2cafd87fb912744a896f53c74aac9402bc087e106eccbf19144b9f93e35","functions":[{"id":"func/MemoTxidAction.constructor","name":"MemoTxidAction.constructor","line":18,"end_line":22,"hash":"399be38855508b45b6a07e00361d4fb36186df3fdac5e2a68e004f7dcbdd353c"},{"id":"func/MemoTxidAction.broadcastTxid","name":"MemoTxidAction.broadcastTxid","line":27,"end_line":49,"hash":"60e57cd10e406285825ad6750d9cce312ea5ecb09e02a697da358ac65f8312a5"},{"id":"func/MemoTxidAction.reflect","name":"MemoTxidAction.reflect","line":54,"end_line":64,"hash":"7d61638162b7142298e021b02cbdeaca359f33503b3b84a308498f0479c80c5c"},{"id":"func/MemoTxidAction.isTooLong","name":"MemoTxidAction.isTooLong","line":68,"end_line":70,"hash":"7eeb0b9dcb35530de412b0aa0ade6b267e2fe22f5e49aa0f3ecf5b9124022a3b"}]}
// mutate4javascript-manifest-end
+11 -5
View File
@@ -7,13 +7,19 @@
length of a string.
*/
// Return the number of UTF-8 bytes in a string.
function byteLength (str) {
return new TextEncoder().encode(String(str)).length
// Encode a string as UTF-8 bytes. TextEncoder is available in both the
// browser and Node.
function encodeUtf8 (str) {
return new TextEncoder().encode(String(str))
}
module.exports = { byteLength }
// Return the number of UTF-8 bytes in a string.
function byteLength (str) {
return encodeUtf8(str).length
}
module.exports = { encodeUtf8, byteLength }
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T12:17:55.885Z","module_hash":"7f91541c49f2b6f421e8d4158bfe808b35a5449534cb26c567162fce6fec64bf","functions":[{"id":"func/byteLength","name":"byteLength","line":11,"end_line":13,"hash":"973c9dadcd1d8bbd53587252443880db13c8be3639fa74ce3c69a08ea358c8e2"}]}
// {"version":1,"tested_at":"2026-09-17T04:11:15.796Z","module_hash":"530eeae383fa202f8e50d83126a9bafaaa49b04268f297e9e91027a89cde71b4","functions":[{"id":"func/encodeUtf8","name":"encodeUtf8","line":12,"end_line":14,"hash":"8d56bdd5607b741578d5f51511391e0376cc410181401b2570fde0990b6bf976"},{"id":"func/byteLength","name":"byteLength","line":17,"end_line":19,"hash":"4d5266d6b3715a761b533e67477846156cfbd497460e5af448a8ea7a72bb580c"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,172 @@
/*
Property tests for the multi-push OP_RETURN adapter.
Unit tests pin the adapter's push expansion at fixed fixtures. These
properties pin the encoding invariants over broad random field lists:
- Ordering and prefix: buildPushes always returns the action prefix first
followed by one push per field, in input order.
- Conservation: the concatenation of the field pushes equals the UTF-8
encoding of the input fields, with no bytes added or dropped.
- Round trip: toPushBuffer encodes a string as its UTF-8 bytes and passes
byte arrays through unchanged.
- Dispatch and idempotence: the attached wallet expands an array argument
to separate pushes, delegates a single-field call to the original
method, and attaching twice does not double-wrap.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const {
toPushBuffer,
buildPushes,
attachMultiPushOpReturn
} = require('../../src/services/memo-multipush')
const { encodeScript } = require('../support/script-encoding')
const rng = seededRandom(20260916)
// Characters with distinct UTF-8 byte widths: 1, 1, 2, 3, and 4 bytes.
const CHARS = ['a', 'b', ' ', '\u00e9', '\u20ac', '\ud83d\ude00']
function randomPrefix () {
const len = 2 * intGen(rng, 1, 4)()
const hex = '0123456789abcdef'
let out = ''
for (let i = 0; i < len; i++) out += hex[Math.floor(rng() * 16)]
return out
}
function randomString () {
const len = intGen(rng, 0, 24)()
let out = ''
for (let i = 0; i < len; i++) out += CHARS[Math.floor(rng() * CHARS.length)]
return out
}
// Half UTF-8 strings, half raw byte arrays.
function randomField () {
if (rng() < 0.5) return randomString()
const len = intGen(rng, 0, 8)()
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) bytes[i] = intGen(rng, 0, 255)()
return bytes
}
function randomFields () {
const count = intGen(rng, 1, 5)()
const fields = []
for (let i = 0; i < count; i++) fields.push(randomField())
return fields
}
// A double of the minimal-slp-wallet surface the adapter reuses.
function makeWalletDouble () {
const sent = []
const wallet = {
walletInfo: { cashAddress: 'bitcoincash:qtest' },
fee: 1,
walletInfoPromise: Promise.resolve(),
utxos: { utxoStore: { bchUtxos: [{ tx_hash: 'aa', tx_pos: 0, value: 100000 }] } },
calls: [],
async sendOpReturn (msg, prefix, bchOutput = []) {
this.calls.push({ msg, prefix, bchOutput })
return 'single-txid'
},
opReturn: {
bchjs: {
Script: { opcodes: { OP_RETURN: 0x6a }, encode2: encodeScript }
},
async createTransaction (walletInfo, bchUtxos, msg, prefix) {
this.lastEncoded = this.bchjs.Script.encode2([
this.bchjs.Script.opcodes.OP_RETURN,
Buffer.from(prefix, 'hex'),
Buffer.from(msg)
])
return { hex: 'beef' }
},
ar: {
async sendTx (hex) {
sent.push(hex)
return 'txid-1'
}
}
}
}
return { wallet, sent }
}
test('buildPushes keeps the prefix first and the fields in order', async () => {
await forAll(
() => ({ prefix: randomPrefix(), fields: randomFields() }),
({ prefix, fields }) => {
const pushes = buildPushes(prefix, fields)
if (pushes.length !== fields.length + 1) return false
if (pushes[0].toString('hex') !== prefix) return false
for (let i = 0; i < fields.length; i++) {
if (!pushes[i + 1].equals(toPushBuffer(fields[i]))) return false
}
return true
},
{ label: 'buildPushes ordering and prefix' }
)
})
test('buildPushes conserves the field bytes', async () => {
await forAll(
() => ({ prefix: randomPrefix(), fields: randomFields() }),
({ prefix, fields }) => {
const pushes = buildPushes(prefix, fields)
const actual = Buffer.concat(pushes.slice(1))
const expected = Buffer.concat(fields.map((field) => toPushBuffer(field)))
return actual.equals(expected)
},
{ label: 'buildPushes conservation' }
)
})
test('toPushBuffer round-trips strings and byte arrays', async () => {
await forAll(
() => randomField(),
(field) => {
const buf = toPushBuffer(field)
if (typeof field === 'string') return buf.toString('utf8') === field
return buf.equals(Buffer.from(field))
},
{ label: 'toPushBuffer round trip' }
)
})
test('an attached wallet expands arrays into separate pushes', async () => {
await forAll(
() => ({ prefix: randomPrefix(), fields: randomFields() }),
async ({ prefix, fields }) => {
const { wallet } = makeWalletDouble()
attachMultiPushOpReturn(wallet)
const txid = await wallet.sendOpReturn(fields, prefix)
if (txid !== 'txid-1') return false
const expected = encodeScript([
wallet.opReturn.bchjs.Script.opcodes.OP_RETURN,
...buildPushes(prefix, fields)
])
return wallet.opReturn.lastEncoded.equals(expected)
},
{ label: 'adapter multi-push expansion' }
)
})
test('an attached wallet delegates a single field and attaches only once', async () => {
await forAll(
() => randomString(),
async (text) => {
const { wallet } = makeWalletDouble()
attachMultiPushOpReturn(wallet)
attachMultiPushOpReturn(wallet)
const txid = await wallet.sendOpReturn(text, '6d02')
return txid === 'single-txid' && wallet.calls.length === 1 && wallet.calls[0].msg === text
},
{ label: 'adapter delegation and idempotent attach' }
)
})
@@ -4,8 +4,8 @@
These pin down invariants over broad random inputs that the unit tests only
probe at fixed fixtures:
- Round-trip: buildTxidTextPayload encodes a poll txid + text into the
canonical Memo wire payload, so the stored reverse-hex txid and the
- Round-trip: buildTxidTextPushes encodes a poll txid + text into the
canonical Memo wire pushes, so the stored reverse-hex txid and the
UTF-8 text both decode back unchanged.
- hexToBytes length contract: only 64-character hex txids decode to
32 bytes; everything else throws.
@@ -19,7 +19,7 @@
const test = require('node:test')
const { seededRandom, forAll } = require('./harness')
const { hexToBytes, buildTxidTextPayload } = require('../../src/services/hex')
const { hexToBytes, buildTxidTextPushes } = require('../../src/services/hex')
const { byteLength } = require('../../src/services/utf8')
const MemoPollOption = require('../../src/services/memo-poll-option')
const MemoPollVote = require('../../src/services/memo-poll-vote')
@@ -61,13 +61,14 @@ function makeWallet () {
}
}
test('buildTxidTextPayload round-trips the canonical Memo wire format', async () => {
test('buildTxidTextPushes round-trips the canonical Memo wire format', async () => {
await forAll(
() => ({ txid: randomTxid(), text: randomText(200) }),
({ txid, text }) => {
const bytes = buildTxidTextPayload(txid, text)
const pushes = buildTxidTextPushes(txid, text)
const bytes = Buffer.concat(pushes.map((p) => Buffer.from(p)))
if (bytes.length !== 32 + byteLength(text)) return false
// The payload carries the txid in little-endian wire order, followed by
// The pushes carry 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))
@@ -18,7 +18,7 @@
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 { txidToWireBytes, buildTxidTextPushes } = require('../../src/services/hex')
const HEX_CHARS = '0123456789abcdef'
@@ -87,8 +87,8 @@ test('payload embeds the wire txid followed by the UTF-8 text', async () => {
await forAll(
() => ({ txid: randomTxid(rng), text: randomText(rng) }),
({ txid, text }) => {
const raw = buildTxidTextPayload(txid, text)
const buf = Buffer.from(raw)
const pushes = buildTxidTextPushes(txid, text)
const buf = Buffer.concat(pushes.map((p) => Buffer.from(p)))
const wire = Buffer.from(txidToWireBytes(txid)).toString('hex')
const expectedText = Buffer.from(text, 'utf8')
@@ -96,13 +96,13 @@ test('payload embeds the wire txid followed by the UTF-8 text', async () => {
if (buf.subarray(0, 32).toString('hex') !== wire) return false
return buf.subarray(32).equals(expectedText)
},
{ label: 'buildTxidTextPayload shape' }
{ label: 'buildTxidTextPushes shape' }
)
})
test('a reply payload rejects an invalid txid with the parent label', () => {
assert.throws(
() => buildTxidTextPayload('not-a-txid', 'hi', 'Parent txid'),
() => buildTxidTextPushes('not-a-txid', 'hi', 'Parent txid'),
/Parent txid must be a 64-character hex string/
)
})
@@ -0,0 +1,20 @@
/*
Test helper: encode a script the way Bitcoin does.
An opcode number is one byte; a Buffer/string becomes a length-prefixed push.
Enough to observe that the multi-push adapter produced the expected pushes.
Shared by the memo-multipush unit and property tests.
*/
'use strict'
function encodeScript (script) {
const parts = script.map((el) => {
if (typeof el === 'number') return Buffer.from([el])
const buf = Buffer.from(el)
return Buffer.concat([Buffer.from([buf.length]), buf])
})
return Buffer.concat(parts)
}
module.exports = { encodeScript }
+12 -13
View File
@@ -4,14 +4,14 @@
Memo actions that embed a parent poll txid use hexToBytes to decode the
64-character hex txid into 32 raw bytes. These direct tests pin the length
and hex-validity guards independently of the memo-poll broadcast path that
also reaches hexToBytes through buildTxidTextPayload.
also reaches hexToBytes through buildTxidTextPushes.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { hexToBytes, buildTxidTextPayload, txidToWireBytes } = require('../../src/services/hex')
const { hexToBytes, buildTxidTextPushes, txidToWireBytes } = require('../../src/services/hex')
// A non-palindromic txid so a missing byte reversal is observable.
const DISPLAY_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
@@ -57,13 +57,13 @@ test('hexToBytes rejects a non-string value', () => {
)
})
test('buildTxidTextPayload prefixes the raw txid bytes', () => {
const raw = buildTxidTextPayload('ab'.repeat(32), 'hi')
const buf = Buffer.from(raw)
test('buildTxidTextPushes returns the txid and text as separate pushes', () => {
const pushes = buildTxidTextPushes('ab'.repeat(32), 'hi')
assert.equal(buf.length, 32 + 2)
assert.equal(buf[0], 0xab)
assert.equal(buf.slice(32).toString('utf8'), 'hi')
assert.equal(pushes.length, 2)
assert.equal(Buffer.from(pushes[0]).length, 32)
assert.equal(Buffer.from(pushes[0])[0], 0xab)
assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hi')
})
test('txidToWireBytes reverses the display txid into little-endian wire order', () => {
@@ -80,10 +80,9 @@ test('txidToWireBytes rejects an invalid txid', () => {
)
})
test('buildTxidTextPayload embeds the txid in little-endian wire order', () => {
const raw = buildTxidTextPayload(DISPLAY_TXID, 'hi')
const buf = Buffer.from(raw)
test('buildTxidTextPushes embeds the txid in little-endian wire order', () => {
const pushes = buildTxidTextPushes(DISPLAY_TXID, 'hi')
assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX)
assert.equal(buf.slice(32).toString('utf8'), 'hi')
assert.equal(Buffer.from(pushes[0]).toString('hex'), WIRE_HEX)
assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hi')
})
@@ -0,0 +1,148 @@
/*
Unit tests for the multi-push OP_RETURN adapter.
Memo actions that carry more than one field must encode each field as its
own OP_RETURN script push. minimal-slp-wallet broadcasts a single msg push,
so the adapter wraps the wallet's sendOpReturn: an array first argument is
expanded to separate pushes, while single-field calls keep delegating to the
original wallet method.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const {
toPushBuffer,
buildPushes,
broadcastMultiPush,
attachMultiPushOpReturn
} = require('../../src/services/memo-multipush')
const { encodeScript } = require('../support/script-encoding')
// A double of the minimal-slp-wallet surface the adapter reuses.
function makeSlpWalletDouble () {
const sent = []
const wallet = {
walletInfo: { cashAddress: 'bitcoincash:qtest' },
fee: 1,
walletInfoPromise: Promise.resolve(),
utxos: { utxoStore: { bchUtxos: [{ tx_hash: 'aa', tx_pos: 0, value: 100000 }] } },
calls: [],
async sendOpReturn (msg, prefix, bchOutput = []) {
this.calls.push({ msg, prefix, bchOutput })
return 'single-txid'
},
opReturn: {
bchjs: {
Script: {
opcodes: { OP_RETURN: 0x6a },
encode2: encodeScript
}
},
async createTransaction (walletInfo, bchUtxos, msg, prefix) {
this.lastScript = [
this.bchjs.Script.opcodes.OP_RETURN,
Buffer.from(prefix, 'hex'),
Buffer.from(msg)
]
this.lastEncoded = this.bchjs.Script.encode2(this.lastScript)
return { hex: 'beef' }
},
ar: {
async sendTx (hex) {
sent.push(hex)
return 'txid-1'
}
}
}
}
return { wallet, sent }
}
test('toPushBuffer encodes a string as UTF-8 and passes bytes through', () => {
assert.equal(toPushBuffer('hi').toString('hex'), '6869')
assert.equal(toPushBuffer(Uint8Array.from([1, 2])).toString('hex'), '0102')
})
// The browser bundle has no global Buffer (CRA 5 does not polyfill Node
// globals and the wallet script does not set window.Buffer), so the adapter
// must import its own. Re-require it with the global removed to prove it.
test('the adapter builds pushes without a browser-global Buffer', () => {
const modulePath = require.resolve('../../src/services/memo-multipush')
const savedBuffer = global.Buffer
global.Buffer = undefined
delete require.cache[modulePath]
try {
const fresh = require('../../src/services/memo-multipush')
assert.equal(fresh.toPushBuffer('hi').toString('hex'), '6869')
assert.equal(fresh.buildPushes('6d03', ['hi'])[0].toString('hex'), '6d03')
} finally {
global.Buffer = savedBuffer
delete require.cache[modulePath]
}
})
test('buildPushes puts the prefix first and each field in order', () => {
const pushes = buildPushes('6d03', [Buffer.from('aa', 'hex'), 'hi'])
assert.equal(pushes.length, 3)
assert.equal(pushes[0].toString('hex'), '6d03')
assert.equal(pushes[1].toString('hex'), 'aa')
assert.equal(pushes[2].toString('utf8'), 'hi')
})
test('array fields broadcast as separate OP_RETURN pushes', async () => {
const { wallet, sent } = makeSlpWalletDouble()
attachMultiPushOpReturn(wallet)
const txid = await wallet.sendOpReturn(
[Buffer.from('aabb', 'hex'), Buffer.from('hello')],
'6d03'
)
assert.equal(txid, 'txid-1')
assert.deepEqual(sent, ['beef'])
// OP_RETURN, push(6d03), push(aabb), push(hello) — each its own push.
assert.equal(
wallet.opReturn.lastEncoded.toString('hex'),
'6a026d0302aabb0568656c6c6f'
)
})
test('a single field delegates to the original wallet sendOpReturn', async () => {
const { wallet } = makeSlpWalletDouble()
attachMultiPushOpReturn(wallet)
const txid = await wallet.sendOpReturn('single', '6d02', [])
assert.equal(txid, 'single-txid')
assert.equal(wallet.calls.length, 1)
assert.equal(wallet.calls[0].msg, 'single')
})
test('attaching twice does not double-wrap the wallet', async () => {
const { wallet } = makeSlpWalletDouble()
attachMultiPushOpReturn(wallet)
const wrapped = wallet.sendOpReturn
attachMultiPushOpReturn(wallet)
// The second attach must leave the wrapper untouched rather than nesting it.
assert.equal(wallet.sendOpReturn, wrapped)
await wallet.sendOpReturn('single', '6d02')
assert.equal(wallet.calls.length, 1)
})
test('attaching to a falsy wallet is a no-op', () => {
assert.equal(attachMultiPushOpReturn(null), null)
assert.equal(attachMultiPushOpReturn(undefined), undefined)
})
test('broadcastMultiPush rejects a wallet without the OP_RETURN builder', async () => {
await assert.rejects(
broadcastMultiPush({ opReturn: {} }, ['hi'], '6d02'),
/does not support multi-push OP_RETURN broadcasts/
)
})
@@ -28,16 +28,7 @@ function makeWallet (address = MY_ADDRESS) {
}
}
function decodePayload (raw) {
const buf = Buffer.from(raw)
return {
pollType: buf[0],
optionCount: buf[1],
question: buf.slice(2).toString('utf8')
}
}
test('create broadcasts with the create-poll prefix and payload', async () => {
test('create broadcasts the poll type, option count, and question as separate pushes', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
@@ -45,10 +36,12 @@ test('create broadcasts with the create-poll prefix and payload', async () => {
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollCreate.MEMO_CREATE_POLL_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg)
assert.equal(decoded.question, 'which is better?')
assert.equal(decoded.optionCount, 2)
assert.equal(decoded.pollType, 1)
const pushes = wallet.broadcasts[0].msg
assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(pushes.length, 3)
assert.equal(Buffer.from(pushes[0])[0], 1)
assert.equal(Buffer.from(pushes[1])[0], 2)
assert.equal(Buffer.from(pushes[2]).toString('utf8'), 'which is better?')
})
test('create accepts an option count of one', async () => {
@@ -30,14 +30,7 @@ function makeWallet (address = MY_ADDRESS) {
}
}
function decodePayload (raw) {
const buf = Buffer.from(raw)
const pollTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex')
const option = buf.slice(32).toString('utf8')
return { pollTxid, option }
}
test('add broadcasts with the add-poll-option prefix and payload', async () => {
test('add broadcasts the poll txid and option as separate pushes', async () => {
const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID })
@@ -45,9 +38,11 @@ test('add broadcasts with the add-poll-option prefix and payload', async () => {
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg)
assert.equal(decoded.pollTxid, POLL_TXID)
assert.equal(decoded.option, 'yes')
const pushes = wallet.broadcasts[0].msg
assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(pushes.length, 2)
assert.equal(Buffer.from(pushes[0]).reverse().toString('hex'), POLL_TXID)
assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'yes')
})
test('add reflects the new option on the injected poll store', async () => {
@@ -30,14 +30,7 @@ function makeWallet (address = MY_ADDRESS) {
}
}
function decodePayload (raw) {
const buf = Buffer.from(raw)
const pollTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex')
const comment = buf.slice(32).toString('utf8')
return { pollTxid, comment }
}
test('vote broadcasts with the poll-vote prefix and payload', async () => {
test('vote broadcasts the poll txid and comment as separate pushes', async () => {
const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID })
@@ -45,9 +38,11 @@ test('vote broadcasts with the poll-vote prefix and payload', async () => {
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollVote.MEMO_POLL_VOTE_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg)
assert.equal(decoded.pollTxid, POLL_TXID)
assert.equal(decoded.comment, 'yes')
const pushes = wallet.broadcasts[0].msg
assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(pushes.length, 2)
assert.equal(Buffer.from(pushes[0]).reverse().toString('hex'), POLL_TXID)
assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'yes')
})
test('vote reflects the new vote on the injected poll store', async () => {
+6 -4
View File
@@ -31,7 +31,7 @@ function makeWallet () {
}
}
test('reply broadcasts the parent txid in little-endian wire order', async () => {
test('reply broadcasts the parent txid and text as separate pushes', async () => {
const wallet = makeWallet()
const memoReply = new MemoReply({ wallet })
@@ -39,9 +39,11 @@ test('reply broadcasts the parent txid in little-endian wire order', async () =>
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')
const pushes = wallet.broadcasts[0].msg
assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(pushes.length, 2)
assert.equal(Buffer.from(pushes[0]).toString('hex'), WIRE_HEX)
assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hello memo')
})
test('isTooLong accepts a message exactly at the byte limit', () => {
@@ -26,7 +26,7 @@ function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26
}
}
test('post broadcasts topic name and message with the topic-message prefix', async () => {
test('post broadcasts topic name and message as separate pushes', async () => {
const wallet = makeWallet()
const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' })
@@ -34,7 +34,11 @@ test('post broadcasts topic name and message with the topic-message prefix', asy
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX)
assert.equal(wallet.broadcasts[0].msg, 'bitcoinhello bitcoin')
const pushes = wallet.broadcasts[0].msg
assert.ok(Array.isArray(pushes), 'expected separate pushes')
assert.equal(pushes.length, 2)
assert.equal(Buffer.from(pushes[0]).toString('utf8'), 'bitcoin')
assert.equal(Buffer.from(pushes[1]).toString('utf8'), 'hello bitcoin')
})
test('post reflects the new topic post on the injected feed', async () => {
@@ -44,6 +44,15 @@ function loadReversedFixture (level) {
level.pollVotesDb.put('vote-2', { pollTxid: DISPLAY_POLL, comment: 'no', blockHeight: 11 })
}
// Load the reversed-reference fixture, run the repair once, and return the
// repaired level for assertions.
async function repairedFixture () {
const level = makeLevel()
loadReversedFixture(level)
await repairTxidEncoding(level)
return level
}
describe('#RepairTxidEncoding', () => {
describe('reverseTxid', () => {
it('reverses the byte order of a display txid', () => {
@@ -98,10 +107,7 @@ describe('#RepairTxidEncoding', () => {
describe('repairTxidEncoding', () => {
it('corrects a reversed like reference and rebuilds postLikes', async () => {
const level = makeLevel()
loadReversedFixture(level)
await repairTxidEncoding(level)
const level = await repairedFixture()
assert.equal((await level.likesDb.get('like-1')).postTxid, DISPLAY_POST)
assert.include(level.postLikesDb.keys(), `${DISPLAY_POST}:like-1`)
@@ -109,10 +115,7 @@ describe('#RepairTxidEncoding', () => {
})
it('corrects a reversed reply reference and rebuilds postChildren', async () => {
const level = makeLevel()
loadReversedFixture(level)
await repairTxidEncoding(level)
const level = await repairedFixture()
assert.equal((await level.postParentsDb.get('reply-1')).parentTxid, DISPLAY_POST)
assert.include(level.postChildrenDb.keys(), `${DISPLAY_POST}:reply-1`)
@@ -120,20 +123,14 @@ describe('#RepairTxidEncoding', () => {
})
it('corrects reversed poll option and vote references', async () => {
const level = makeLevel()
loadReversedFixture(level)
await repairTxidEncoding(level)
const level = await repairedFixture()
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)
const level = await repairedFixture()
assert.equal((await level.likesDb.get('like-2')).postTxid, DISPLAY_POST)
assert.equal((await level.postParentsDb.get('reply-2')).parentTxid, DISPLAY_POST)
@@ -142,10 +139,7 @@ describe('#RepairTxidEncoding', () => {
})
it('leaves an unknown reference unchanged', async () => {
const level = makeLevel()
loadReversedFixture(level)
await repairTxidEncoding(level)
const level = await repairedFixture()
assert.equal((await level.likesDb.get('like-3')).postTxid, UNKNOWN)
})
@@ -163,10 +157,7 @@ describe('#RepairTxidEncoding', () => {
})
it('is idempotent', async () => {
const level = makeLevel()
loadReversedFixture(level)
await repairTxidEncoding(level)
const level = await repairedFixture()
await repairTxidEncoding(level)
const postLikes = level.postLikesDb.keys()