mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge memo-multipush-encoding from swarmforge-architect
By specifier.
This commit is contained in:
@@ -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,
|
(`--json`) did. Redirect stdout to a file and use `--json` for a reliable,
|
||||||
parseable record of killed/survived mutations.
|
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.**
|
- **`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
|
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`
|
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"
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ const { renderPostOptions } = require('./render-post-options')
|
|||||||
const { renderLikeResult } = require('./render-like-result')
|
const { renderLikeResult } = require('./render-like-result')
|
||||||
const PostOptions = require('../../src/services/post-options')
|
const PostOptions = require('../../src/services/post-options')
|
||||||
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
|
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_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||||
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
|
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
|
||||||
@@ -102,12 +103,19 @@ function makeWallet (address) {
|
|||||||
return this.utxos
|
return this.utxos
|
||||||
},
|
},
|
||||||
sendOpReturn: async function (msg, prefix, bchOutput = []) {
|
sendOpReturn: async function (msg, prefix, bchOutput = []) {
|
||||||
// Normalize binary payloads to Buffer so assertions can safely use
|
// Multi-field actions pass an array of field pushes. Expose each push
|
||||||
// toString('hex'), while preserving string payloads unchanged.
|
// separately for the multi-push assertions, while keeping `msg` as the
|
||||||
const storedMsg = (msg instanceof Uint8Array || ArrayBuffer.isView(msg))
|
// 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)
|
? Buffer.from(msg)
|
||||||
: msg
|
: msg)
|
||||||
this.broadcasts.push({ msg: storedMsg, prefix, bchOutput })
|
const pushes = [Buffer.from(prefix, 'hex'), ...fieldBuffers]
|
||||||
|
this.broadcasts.push({ msg: storedMsg, pushes, prefix, bchOutput })
|
||||||
if (this.failWith) throw new Error(this.failWith)
|
if (this.failWith) throw new Error(this.failWith)
|
||||||
return 'aa'.repeat(32)
|
return 'aa'.repeat(32)
|
||||||
}
|
}
|
||||||
@@ -559,6 +567,38 @@ function decodeLikeTxid (raw) {
|
|||||||
return Buffer.from(raw).reverse().toString('hex')
|
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.
|
// Resolve a literal value or a <parameter> placeholder from the example store.
|
||||||
function resolveParam (value, example) {
|
function resolveParam (value, example) {
|
||||||
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim())
|
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',
|
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_]+)>"$/,
|
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}".`)
|
throw new Error(`Expected Memo topic-message prefix ${MEMO_TOPIC_MESSAGE_PREFIX}, got "${last.prefix}".`)
|
||||||
}
|
}
|
||||||
const expectedPayload = room + world.topicPostPage.input
|
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.`)
|
throw new Error(`Broadcast topic-message payload did not match ${room} + input.`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
-3
@@ -17,6 +17,7 @@
|
|||||||
"bch-message-lib": "2.2.1",
|
"bch-message-lib": "2.2.1",
|
||||||
"bch-token-sweep": "2.2.1",
|
"bch-token-sweep": "2.2.1",
|
||||||
"bootstrap": "5.2.0",
|
"bootstrap": "5.2.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"query-string": "7.1.1",
|
"query-string": "7.1.1",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
@@ -6160,7 +6161,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6637,7 +6637,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"dev": true,
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -11703,7 +11704,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"bch-message-lib": "2.2.1",
|
"bch-message-lib": "2.2.1",
|
||||||
"bch-token-sweep": "2.2.1",
|
"bch-token-sweep": "2.2.1",
|
||||||
"bootstrap": "5.2.0",
|
"bootstrap": "5.2.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"query-string": "7.1.1",
|
"query-string": "7.1.1",
|
||||||
"react": "19.0.0",
|
"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
|
# 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
|
# Memo actions that carry more than one field encode each protocol field as
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
// Local libraries
|
// Local libraries
|
||||||
import GistServers from './gist-servers'
|
import GistServers from './gist-servers'
|
||||||
|
import memoMultipush from './memo-multipush'
|
||||||
|
|
||||||
class AsyncLoad {
|
class AsyncLoad {
|
||||||
constructor () {
|
constructor () {
|
||||||
@@ -66,6 +67,10 @@ class AsyncLoad {
|
|||||||
|
|
||||||
this.wallet = wallet
|
this.wallet = wallet
|
||||||
|
|
||||||
|
// Teach the wallet to broadcast multi-field Memo actions as separate
|
||||||
|
// OP_RETURN pushes.
|
||||||
|
memoMultipush.attachMultiPushOpReturn(wallet)
|
||||||
|
|
||||||
return wallet
|
return wallet
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing wallet: ', error)
|
console.error('Error initializing wallet: ', error)
|
||||||
@@ -240,6 +245,10 @@ class AsyncLoad {
|
|||||||
|
|
||||||
this.wallet = wallet
|
this.wallet = wallet
|
||||||
|
|
||||||
|
// Teach the wallet to broadcast multi-field Memo actions as separate
|
||||||
|
// OP_RETURN pushes.
|
||||||
|
memoMultipush.attachMultiPushOpReturn(wallet)
|
||||||
|
|
||||||
return wallet
|
return wallet
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initStarterWallet: ', error)
|
console.error('Error initStarterWallet: ', error)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
testable conversion helper.
|
testable conversion helper.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { encodeUtf8 } = require('./utf8')
|
||||||
|
|
||||||
// Decode a hex string into a Uint8Array of the requested byte length.
|
// Decode a hex string into a Uint8Array of the requested byte length.
|
||||||
// The label parameter customizes error messages for the caller's context.
|
// The label parameter customizes error messages for the caller's context.
|
||||||
function hexToBytes (hex, byteLength = 32, label = 'Value') {
|
function hexToBytes (hex, byteLength = 32, label = 'Value') {
|
||||||
@@ -33,20 +35,17 @@ function txidToWireBytes (txid, label = 'Value') {
|
|||||||
return hexToBytes(txid, 32, label).reverse()
|
return hexToBytes(txid, 32, label).reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the raw OP_RETURN payload for a txid-referencing Memo action: the
|
// Build the separate OP_RETURN pushes for a txid-referencing Memo action:
|
||||||
// given 32-byte txid in little-endian wire order followed by a UTF-8 encoded
|
// the referenced txid in little-endian wire order and the UTF-8 encoded value,
|
||||||
// value. The label customizes the invalid-txid error message.
|
// each as its own push. The label customizes the invalid-txid error message.
|
||||||
function buildTxidTextPayload (txid, text, label = 'Poll txid') {
|
function buildTxidTextPushes (txid, text, label = 'Poll txid') {
|
||||||
const txidBytes = txidToWireBytes(txid, label)
|
const txidBytes = txidToWireBytes(txid, label)
|
||||||
const textBytes = new TextEncoder().encode(text)
|
const textBytes = encodeUtf8(text)
|
||||||
const raw = new Uint8Array(txidBytes.length + textBytes.length)
|
return [txidBytes, textBytes]
|
||||||
raw.set(txidBytes, 0)
|
|
||||||
raw.set(textBytes, txidBytes.length)
|
|
||||||
return raw
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPayload }
|
module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPushes }
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// 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
|
// 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 MemoAction = require('./memo-action')
|
||||||
const { byteLength } = require('./utf8')
|
const { byteLength, encodeUtf8 } = require('./utf8')
|
||||||
|
|
||||||
const MEMO_CREATE_POLL_PREFIX = '6d10'
|
const MEMO_CREATE_POLL_PREFIX = '6d10'
|
||||||
const MAX_QUESTION_BYTES = 209
|
const MAX_QUESTION_BYTES = 209
|
||||||
@@ -62,8 +62,8 @@ class MemoPollCreate extends MemoAction {
|
|||||||
|
|
||||||
await this.wallet.getUtxos()
|
await this.wallet.getUtxos()
|
||||||
|
|
||||||
const raw = buildCreatePollPayload(question, this.pollType, count)
|
const pushes = buildCreatePollPushes(question, this.pollType, count)
|
||||||
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
|
const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
|
||||||
|
|
||||||
this.reflect(txid, question, count)
|
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.
|
// Build the separate OP_RETURN pushes for a create-poll action: the poll type
|
||||||
// The protocol wire format is: <poll_type 1 byte><option_count 1 byte><question UTF-8 bytes>.
|
// byte, the option count byte, and the UTF-8 question, each as its own push.
|
||||||
function buildCreatePollPayload (question, pollType, optionCount) {
|
function buildCreatePollPushes (question, pollType, optionCount) {
|
||||||
const textBytes = new TextEncoder().encode(question)
|
const textBytes = encodeUtf8(question)
|
||||||
const raw = new Uint8Array(2 + textBytes.length)
|
return [
|
||||||
raw[0] = pollType & 0xff
|
Uint8Array.from([pollType & 0xff]),
|
||||||
raw[1] = optionCount & 0xff
|
Uint8Array.from([optionCount & 0xff]),
|
||||||
raw.set(textBytes, 2)
|
textBytes
|
||||||
return raw
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
MemoPollCreate.MEMO_CREATE_POLL_PREFIX = MEMO_CREATE_POLL_PREFIX
|
MemoPollCreate.MEMO_CREATE_POLL_PREFIX = MEMO_CREATE_POLL_PREFIX
|
||||||
@@ -102,5 +102,5 @@ MemoPollCreate.DEFAULT_POLL_TYPE = DEFAULT_POLL_TYPE
|
|||||||
module.exports = MemoPollCreate
|
module.exports = MemoPollCreate
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// 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
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const MemoTxidAction = require('./memo-txid-action')
|
const MemoTxidAction = require('./memo-txid-action')
|
||||||
const { buildTxidTextPayload } = require('./hex')
|
const { buildTxidTextPushes } = require('./hex')
|
||||||
|
|
||||||
const MEMO_ADD_POLL_OPTION_PREFIX = '6d13'
|
const MEMO_ADD_POLL_OPTION_PREFIX = '6d13'
|
||||||
const MAX_OPTION_BYTES = 184
|
const MAX_OPTION_BYTES = 184
|
||||||
@@ -35,7 +35,7 @@ class MemoPollOption extends MemoTxidAction {
|
|||||||
|
|
||||||
// Compose and broadcast a Memo add-poll-option action.
|
// Compose and broadcast a Memo add-poll-option action.
|
||||||
add (option) {
|
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
|
module.exports = MemoPollOption
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// 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
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const MemoTxidAction = require('./memo-txid-action')
|
const MemoTxidAction = require('./memo-txid-action')
|
||||||
const { buildTxidTextPayload } = require('./hex')
|
const { buildTxidTextPushes } = require('./hex')
|
||||||
|
|
||||||
const MEMO_POLL_VOTE_PREFIX = '6d14'
|
const MEMO_POLL_VOTE_PREFIX = '6d14'
|
||||||
const MAX_COMMENT_BYTES = 184
|
const MAX_COMMENT_BYTES = 184
|
||||||
@@ -35,7 +35,7 @@ class MemoPollVote extends MemoTxidAction {
|
|||||||
|
|
||||||
// Compose and broadcast a Memo poll-vote action.
|
// Compose and broadcast a Memo poll-vote action.
|
||||||
vote (comment) {
|
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
|
module.exports = MemoPollVote
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// 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
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
const MemoAction = require('./memo-action')
|
const MemoAction = require('./memo-action')
|
||||||
const { byteLength } = require('./utf8')
|
const { byteLength } = require('./utf8')
|
||||||
const { buildTxidTextPayload } = require('./hex')
|
const { buildTxidTextPushes } = require('./hex')
|
||||||
|
|
||||||
const MEMO_REPLY_PREFIX = '6d03'
|
const MEMO_REPLY_PREFIX = '6d03'
|
||||||
const MAX_REPLY_BYTES = 184
|
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.
|
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
|
||||||
await this.wallet.getUtxos()
|
await this.wallet.getUtxos()
|
||||||
|
|
||||||
// Build the raw payload: parent txid bytes followed by UTF-8 message bytes.
|
// Build the separate pushes: parent txid bytes, then UTF-8 message bytes.
|
||||||
const raw = buildTxidTextPayload(parentTxid, message, 'Parent txid')
|
const pushes = buildTxidTextPushes(parentTxid, message, 'Parent txid')
|
||||||
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
|
const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
|
||||||
|
|
||||||
// Reflect the result on the injected thread once broadcast succeeds.
|
// Reflect the result on the injected thread once broadcast succeeds.
|
||||||
this.reflect(txid, message, parentTxid)
|
this.reflect(txid, message, parentTxid)
|
||||||
@@ -85,5 +85,5 @@ MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES
|
|||||||
module.exports = MemoReply
|
module.exports = MemoReply
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// 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
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const MemoAction = require('./memo-action')
|
const MemoAction = require('./memo-action')
|
||||||
const { byteLength } = require('./utf8')
|
const { byteLength, encodeUtf8 } = require('./utf8')
|
||||||
|
|
||||||
const MEMO_TOPIC_MESSAGE_PREFIX = '6d0c'
|
const MEMO_TOPIC_MESSAGE_PREFIX = '6d0c'
|
||||||
const MAX_TOPIC_MESSAGE_BYTES = 214
|
const MAX_TOPIC_MESSAGE_BYTES = 214
|
||||||
@@ -58,8 +58,11 @@ class MemoTopicPost extends MemoAction {
|
|||||||
|
|
||||||
await this.wallet.getUtxos()
|
await this.wallet.getUtxos()
|
||||||
|
|
||||||
const payload = this.room + message
|
const pushes = [
|
||||||
const txid = await this.wallet.sendOpReturn(payload, this.prefix)
|
encodeUtf8(this.room),
|
||||||
|
encodeUtf8(message)
|
||||||
|
]
|
||||||
|
const txid = await this.wallet.sendOpReturn(pushes, this.prefix)
|
||||||
|
|
||||||
this.reflect(txid, message)
|
this.reflect(txid, message)
|
||||||
|
|
||||||
@@ -85,5 +88,5 @@ MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES = MAX_TOPIC_MESSAGE_BYTES
|
|||||||
module.exports = MemoTopicPost
|
module.exports = MemoTopicPost
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// 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
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ class MemoTxidAction extends MemoAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compose and broadcast an action that embeds this.pollTxid plus the given
|
// Compose and broadcast an action that embeds this.pollTxid plus the given
|
||||||
// value through the supplied buildPayload(pollTxid, value) function.
|
// value. buildPushes(pollTxid, value) returns the ordered OP_RETURN field
|
||||||
async broadcastTxid (value, buildPayload) {
|
// pushes for the action.
|
||||||
|
async broadcastTxid (value, buildPushes) {
|
||||||
const check = this.validate(value)
|
const check = this.validate(value)
|
||||||
this._throwIfInvalid(check)
|
this._throwIfInvalid(check)
|
||||||
|
|
||||||
@@ -39,8 +40,8 @@ class MemoTxidAction extends MemoAction {
|
|||||||
|
|
||||||
await this.wallet.getUtxos()
|
await this.wallet.getUtxos()
|
||||||
|
|
||||||
const raw = buildPayload(this.pollTxid, value)
|
const fields = buildPushes(this.pollTxid, value)
|
||||||
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
|
const txid = await this.wallet.sendOpReturn(fields, this.prefix)
|
||||||
|
|
||||||
this.reflect(txid, value)
|
this.reflect(txid, value)
|
||||||
|
|
||||||
@@ -72,5 +73,5 @@ class MemoTxidAction extends MemoAction {
|
|||||||
module.exports = MemoTxidAction
|
module.exports = MemoTxidAction
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// 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
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -7,13 +7,19 @@
|
|||||||
length of a string.
|
length of a string.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Return the number of UTF-8 bytes in a string.
|
// Encode a string as UTF-8 bytes. TextEncoder is available in both the
|
||||||
function byteLength (str) {
|
// browser and Node.
|
||||||
return new TextEncoder().encode(String(str)).length
|
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
|
// 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
|
// 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
|
These pin down invariants over broad random inputs that the unit tests only
|
||||||
probe at fixed fixtures:
|
probe at fixed fixtures:
|
||||||
|
|
||||||
- Round-trip: buildTxidTextPayload encodes a poll txid + text into the
|
- Round-trip: buildTxidTextPushes encodes a poll txid + text into the
|
||||||
canonical Memo wire payload, so the stored reverse-hex txid and the
|
canonical Memo wire pushes, so the stored reverse-hex txid and the
|
||||||
UTF-8 text both decode back unchanged.
|
UTF-8 text both decode back unchanged.
|
||||||
- hexToBytes length contract: only 64-character hex txids decode to
|
- hexToBytes length contract: only 64-character hex txids decode to
|
||||||
32 bytes; everything else throws.
|
32 bytes; everything else throws.
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
const test = require('node:test')
|
const test = require('node:test')
|
||||||
const { seededRandom, forAll } = require('./harness')
|
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 { byteLength } = require('../../src/services/utf8')
|
||||||
const MemoPollOption = require('../../src/services/memo-poll-option')
|
const MemoPollOption = require('../../src/services/memo-poll-option')
|
||||||
const MemoPollVote = require('../../src/services/memo-poll-vote')
|
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(
|
await forAll(
|
||||||
() => ({ txid: randomTxid(), text: randomText(200) }),
|
() => ({ txid: randomTxid(), text: randomText(200) }),
|
||||||
({ txid, text }) => {
|
({ 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
|
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 UTF-8 bytes of the value. Reversing the wire bytes must recover
|
||||||
// the 64-character display txid.
|
// the 64-character display txid.
|
||||||
const wire = Buffer.from(bytes.slice(0, 32))
|
const wire = Buffer.from(bytes.slice(0, 32))
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
const test = require('node:test')
|
const test = require('node:test')
|
||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
const { seededRandom, forAll, intGen } = require('./harness')
|
const { seededRandom, forAll, intGen } = require('./harness')
|
||||||
const { txidToWireBytes, buildTxidTextPayload } = require('../../src/services/hex')
|
const { txidToWireBytes, buildTxidTextPushes } = require('../../src/services/hex')
|
||||||
|
|
||||||
const HEX_CHARS = '0123456789abcdef'
|
const HEX_CHARS = '0123456789abcdef'
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ test('payload embeds the wire txid followed by the UTF-8 text', async () => {
|
|||||||
await forAll(
|
await forAll(
|
||||||
() => ({ txid: randomTxid(rng), text: randomText(rng) }),
|
() => ({ txid: randomTxid(rng), text: randomText(rng) }),
|
||||||
({ txid, text }) => {
|
({ txid, text }) => {
|
||||||
const raw = buildTxidTextPayload(txid, text)
|
const pushes = buildTxidTextPushes(txid, text)
|
||||||
const buf = Buffer.from(raw)
|
const buf = Buffer.concat(pushes.map((p) => Buffer.from(p)))
|
||||||
const wire = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
const wire = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||||
const expectedText = Buffer.from(text, 'utf8')
|
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
|
if (buf.subarray(0, 32).toString('hex') !== wire) return false
|
||||||
return buf.subarray(32).equals(expectedText)
|
return buf.subarray(32).equals(expectedText)
|
||||||
},
|
},
|
||||||
{ label: 'buildTxidTextPayload shape' }
|
{ label: 'buildTxidTextPushes shape' }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a reply payload rejects an invalid txid with the parent label', () => {
|
test('a reply payload rejects an invalid txid with the parent label', () => {
|
||||||
assert.throws(
|
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/
|
/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 }
|
||||||
@@ -4,14 +4,14 @@
|
|||||||
Memo actions that embed a parent poll txid use hexToBytes to decode the
|
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
|
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
|
and hex-validity guards independently of the memo-poll broadcast path that
|
||||||
also reaches hexToBytes through buildTxidTextPayload.
|
also reaches hexToBytes through buildTxidTextPushes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const test = require('node:test')
|
const test = require('node:test')
|
||||||
const assert = require('node:assert/strict')
|
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.
|
// A non-palindromic txid so a missing byte reversal is observable.
|
||||||
const DISPLAY_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
const DISPLAY_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||||
@@ -57,13 +57,13 @@ test('hexToBytes rejects a non-string value', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildTxidTextPayload prefixes the raw txid bytes', () => {
|
test('buildTxidTextPushes returns the txid and text as separate pushes', () => {
|
||||||
const raw = buildTxidTextPayload('ab'.repeat(32), 'hi')
|
const pushes = buildTxidTextPushes('ab'.repeat(32), 'hi')
|
||||||
const buf = Buffer.from(raw)
|
|
||||||
|
|
||||||
assert.equal(buf.length, 32 + 2)
|
assert.equal(pushes.length, 2)
|
||||||
assert.equal(buf[0], 0xab)
|
assert.equal(Buffer.from(pushes[0]).length, 32)
|
||||||
assert.equal(buf.slice(32).toString('utf8'), 'hi')
|
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', () => {
|
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', () => {
|
test('buildTxidTextPushes embeds the txid in little-endian wire order', () => {
|
||||||
const raw = buildTxidTextPayload(DISPLAY_TXID, 'hi')
|
const pushes = buildTxidTextPushes(DISPLAY_TXID, 'hi')
|
||||||
const buf = Buffer.from(raw)
|
|
||||||
|
|
||||||
assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX)
|
assert.equal(Buffer.from(pushes[0]).toString('hex'), WIRE_HEX)
|
||||||
assert.equal(buf.slice(32).toString('utf8'), 'hi')
|
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) {
|
test('create broadcasts the poll type, option count, and question as separate pushes', async () => {
|
||||||
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 () => {
|
|
||||||
const wallet = makeWallet()
|
const wallet = makeWallet()
|
||||||
const memoPollCreate = new MemoPollCreate({ wallet })
|
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.length, 1)
|
||||||
assert.equal(wallet.broadcasts[0].prefix, MemoPollCreate.MEMO_CREATE_POLL_PREFIX)
|
assert.equal(wallet.broadcasts[0].prefix, MemoPollCreate.MEMO_CREATE_POLL_PREFIX)
|
||||||
const decoded = decodePayload(wallet.broadcasts[0].msg)
|
const pushes = wallet.broadcasts[0].msg
|
||||||
assert.equal(decoded.question, 'which is better?')
|
assert.ok(Array.isArray(pushes), 'expected separate pushes')
|
||||||
assert.equal(decoded.optionCount, 2)
|
assert.equal(pushes.length, 3)
|
||||||
assert.equal(decoded.pollType, 1)
|
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 () => {
|
test('create accepts an option count of one', async () => {
|
||||||
|
|||||||
@@ -30,14 +30,7 @@ function makeWallet (address = MY_ADDRESS) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodePayload (raw) {
|
test('add broadcasts the poll txid and option as separate pushes', async () => {
|
||||||
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 () => {
|
|
||||||
const wallet = makeWallet()
|
const wallet = makeWallet()
|
||||||
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID })
|
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.length, 1)
|
||||||
assert.equal(wallet.broadcasts[0].prefix, MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX)
|
assert.equal(wallet.broadcasts[0].prefix, MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX)
|
||||||
const decoded = decodePayload(wallet.broadcasts[0].msg)
|
const pushes = wallet.broadcasts[0].msg
|
||||||
assert.equal(decoded.pollTxid, POLL_TXID)
|
assert.ok(Array.isArray(pushes), 'expected separate pushes')
|
||||||
assert.equal(decoded.option, 'yes')
|
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 () => {
|
test('add reflects the new option on the injected poll store', async () => {
|
||||||
|
|||||||
@@ -30,14 +30,7 @@ function makeWallet (address = MY_ADDRESS) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodePayload (raw) {
|
test('vote broadcasts the poll txid and comment as separate pushes', async () => {
|
||||||
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 () => {
|
|
||||||
const wallet = makeWallet()
|
const wallet = makeWallet()
|
||||||
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID })
|
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.length, 1)
|
||||||
assert.equal(wallet.broadcasts[0].prefix, MemoPollVote.MEMO_POLL_VOTE_PREFIX)
|
assert.equal(wallet.broadcasts[0].prefix, MemoPollVote.MEMO_POLL_VOTE_PREFIX)
|
||||||
const decoded = decodePayload(wallet.broadcasts[0].msg)
|
const pushes = wallet.broadcasts[0].msg
|
||||||
assert.equal(decoded.pollTxid, POLL_TXID)
|
assert.ok(Array.isArray(pushes), 'expected separate pushes')
|
||||||
assert.equal(decoded.comment, 'yes')
|
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 () => {
|
test('vote reflects the new vote on the injected poll store', async () => {
|
||||||
|
|||||||
@@ -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 wallet = makeWallet()
|
||||||
const memoReply = new MemoReply({ wallet })
|
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.length, 1)
|
||||||
assert.equal(wallet.broadcasts[0].prefix, MemoReply.MEMO_REPLY_PREFIX)
|
assert.equal(wallet.broadcasts[0].prefix, MemoReply.MEMO_REPLY_PREFIX)
|
||||||
const buf = Buffer.from(wallet.broadcasts[0].msg)
|
const pushes = wallet.broadcasts[0].msg
|
||||||
assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX)
|
assert.ok(Array.isArray(pushes), 'expected separate pushes')
|
||||||
assert.equal(buf.slice(32).toString('utf8'), 'hello memo')
|
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', () => {
|
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 wallet = makeWallet()
|
||||||
const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' })
|
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.length, 1)
|
||||||
assert.equal(wallet.broadcasts[0].prefix, MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX)
|
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 () => {
|
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 })
|
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('#RepairTxidEncoding', () => {
|
||||||
describe('reverseTxid', () => {
|
describe('reverseTxid', () => {
|
||||||
it('reverses the byte order of a display txid', () => {
|
it('reverses the byte order of a display txid', () => {
|
||||||
@@ -98,10 +107,7 @@ describe('#RepairTxidEncoding', () => {
|
|||||||
|
|
||||||
describe('repairTxidEncoding', () => {
|
describe('repairTxidEncoding', () => {
|
||||||
it('corrects a reversed like reference and rebuilds postLikes', async () => {
|
it('corrects a reversed like reference and rebuilds postLikes', async () => {
|
||||||
const level = makeLevel()
|
const level = await repairedFixture()
|
||||||
loadReversedFixture(level)
|
|
||||||
|
|
||||||
await repairTxidEncoding(level)
|
|
||||||
|
|
||||||
assert.equal((await level.likesDb.get('like-1')).postTxid, DISPLAY_POST)
|
assert.equal((await level.likesDb.get('like-1')).postTxid, DISPLAY_POST)
|
||||||
assert.include(level.postLikesDb.keys(), `${DISPLAY_POST}:like-1`)
|
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 () => {
|
it('corrects a reversed reply reference and rebuilds postChildren', async () => {
|
||||||
const level = makeLevel()
|
const level = await repairedFixture()
|
||||||
loadReversedFixture(level)
|
|
||||||
|
|
||||||
await repairTxidEncoding(level)
|
|
||||||
|
|
||||||
assert.equal((await level.postParentsDb.get('reply-1')).parentTxid, DISPLAY_POST)
|
assert.equal((await level.postParentsDb.get('reply-1')).parentTxid, DISPLAY_POST)
|
||||||
assert.include(level.postChildrenDb.keys(), `${DISPLAY_POST}:reply-1`)
|
assert.include(level.postChildrenDb.keys(), `${DISPLAY_POST}:reply-1`)
|
||||||
@@ -120,20 +123,14 @@ describe('#RepairTxidEncoding', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('corrects reversed poll option and vote references', async () => {
|
it('corrects reversed poll option and vote references', async () => {
|
||||||
const level = makeLevel()
|
const level = await repairedFixture()
|
||||||
loadReversedFixture(level)
|
|
||||||
|
|
||||||
await repairTxidEncoding(level)
|
|
||||||
|
|
||||||
assert.equal((await level.pollOptionsDb.get('option-1')).pollTxid, DISPLAY_POLL)
|
assert.equal((await level.pollOptionsDb.get('option-1')).pollTxid, DISPLAY_POLL)
|
||||||
assert.equal((await level.pollVotesDb.get('vote-1')).pollTxid, DISPLAY_POLL)
|
assert.equal((await level.pollVotesDb.get('vote-1')).pollTxid, DISPLAY_POLL)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('leaves correctly-encoded references unchanged', async () => {
|
it('leaves correctly-encoded references unchanged', async () => {
|
||||||
const level = makeLevel()
|
const level = await repairedFixture()
|
||||||
loadReversedFixture(level)
|
|
||||||
|
|
||||||
await repairTxidEncoding(level)
|
|
||||||
|
|
||||||
assert.equal((await level.likesDb.get('like-2')).postTxid, DISPLAY_POST)
|
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.postParentsDb.get('reply-2')).parentTxid, DISPLAY_POST)
|
||||||
@@ -142,10 +139,7 @@ describe('#RepairTxidEncoding', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('leaves an unknown reference unchanged', async () => {
|
it('leaves an unknown reference unchanged', async () => {
|
||||||
const level = makeLevel()
|
const level = await repairedFixture()
|
||||||
loadReversedFixture(level)
|
|
||||||
|
|
||||||
await repairTxidEncoding(level)
|
|
||||||
|
|
||||||
assert.equal((await level.likesDb.get('like-3')).postTxid, UNKNOWN)
|
assert.equal((await level.likesDb.get('like-3')).postTxid, UNKNOWN)
|
||||||
})
|
})
|
||||||
@@ -163,10 +157,7 @@ describe('#RepairTxidEncoding', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('is idempotent', async () => {
|
it('is idempotent', async () => {
|
||||||
const level = makeLevel()
|
const level = await repairedFixture()
|
||||||
loadReversedFixture(level)
|
|
||||||
|
|
||||||
await repairTxidEncoding(level)
|
|
||||||
await repairTxidEncoding(level)
|
await repairTxidEncoding(level)
|
||||||
|
|
||||||
const postLikes = level.postLikesDb.keys()
|
const postLikes = level.postLikesDb.keys()
|
||||||
|
|||||||
Reference in New Issue
Block a user