Skip to content

Multisig over rust-miniscript: WASM descriptor engine, descriptor-derived wallets, co-signer trailing-path preservation - #244

Merged
w-s-bitcoin merged 8 commits into
rockfrom
feat/miniscript-descriptor-engine
Sep 1, 2026
Merged

w-s-bitcoin merged 8 commits into
rockfrom
feat/miniscript-descriptor-engine

Conversation

@portlandhodl

@portlandhodl portlandhodl commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

What

One multisig overhaul, rebased onto current rock:

  1. Output-descriptor engine in the WASM crate (el_desc_derive, new entropylab-wasm/src/descriptor.rs). rust-miniscript 13.1.0 (exact pin, committed Cargo.lock) parses descriptors — BIP380–386 key expressions, xpub and xprv/WIF alike, a supplied #checksum verified — and derives the address, scriptPubKey, and participant keys at a child index.
  2. Multisig wallets derive from their exported descriptors. hodlBuildMsig derives each address from the branch descriptor itself, so the addresses shown cannot drift from the watch-only descriptor the wallet exports (previously two parallel code paths). hodlMsigAddr (used by address-match) goes through the same engine. Sorting moves from a JS byte-compare into sortedmulti/sortedmulti_a descriptor semantics. Co-signer identity defenses unchanged — the engine returns the derived participant keys and the final no-repeated-key check stays verbatim.
  3. Co-signer trailing paths: full preservation + fuzz gate. A paste like [fd802fac/48h/0h/0h/2h]Zpub…/0/1/0 used to die as "Invalid Base58Check string". Now a trailing numeric path is honored in full as descriptor key derivation — a bare xpub…/1 (the key-reuse flow), a deep xpub…/0/0/20, and a deep signer export with the branch wildcard xpub…/0/0/20/* alike; the exported descriptor carries every step before the branch step (xpub/0/0/20/* exports as xpub/0/0/20/<0;1>/*), and the engine-derived addresses match it by construction (point 2). The only decoration is the branch marker itself: a sole /0/*, the multipath /<0;1>/*, a lone trailing slash, and the BIP45 cosigner step. The parser tokenizes the tail instead of regex-stripping: a wildcard is only legal last, a multipath step only last-but-wildcard and after plain numbers, anything else fails loudly. The field filter keeps * and < > ; so a pasted /<0;1> reaches the parser unmangled instead of being silently honored as a made-up numeric path (/01); honored steps are canonicalized (/01 exports as /1); the paste hint says when branches derive below an honored path; and the key-origin purpose decodes the multisig script type for the singlesig BIPs (m/44h → Legacy P2SH, m/49h → Nested SegWit, m/84h → Native SegWit). The fuzzer pins both legs — decoration byte-identity and honored-path correctness (with and without the wildcard) — against an independent scure stack.

Rebase notes (vs the original push)

  • Trailing-path semantics reconciled with rock's key-reuse feature (Add Station workflows and cross-station key selection #248-era): the original push stripped all tails, then honored only ≥3-step tails as fixed steps, which would have broken the merged per-co-signer pickers (xpub…/1 reuse). The landed rule is the union of both designs: every trailing numeric path is honored in full (any depth, with or without the wildcard), and only the sole branch marker (/0/*, /<0;1>/*) is decoration. One merged-test expectation changes deliberately: msig-key-derivation-path.test.mjs now expects /1/0/* → 1/0 (deep tail preserved) instead of 1; the test comment says why.
  • The "Multisig Edit input restores the wallet's own inputs" commit reduced to its browser-level regression test: the behavior itself was already on rock via Restore folder tabs, Edit input memory, and LifeHash rename #237 (hodlEditMsigInputs/hodlFillMsigLabFromWallet).
  • Identifier-rename fallout from b82af0f fixed in passing: descriptor.rs calls network_from_selector, and the crate's own test module + doc comments referenced the pre-rename secp_alloc/secp_free (broke cargo test on rock itself — CI only builds the crate).

Scope notes

Taproot sortedmulti_a is layered on top of rust-miniscript in the crate (derived, sorted as x-only bytes, rewritten to the multi_a it denotes — the emitted descriptor text is unchanged). Multipath <0;1> descriptors are refused by the derive call (one call = one output); the exported multipath form is display/import text, as before. The original push also carried BIP390 musig() key expressions (hand-layered BIP327/328, upstream support still in review at rust-bitcoin/rust-miniscript#954) — dropped for now in the tip commit; a descriptor containing musig() fails loudly in rust-miniscript's parser. It can return once upstream ships it.

Notes

  • Addresses and descriptors are byte-identical to before for every existing flow — proven by the untouched scure differential sweep in msig-address-kinds.test.mjs, now exercising the engine path.
  • The fuzzer (fuzzing/msig/, own pinned scure tree, deterministic seeds) immediately caught a real case: a lone trailing / survived the first cut of the strip — included as a regression test.
  • src/js/entropylab-wasm-b64.js regenerated and committed per convention (CI rebuilds from the Rust sources and tests the fresh build). Artifact grows ~520 KB of wasm for miniscript; entropylab.html rebuilt in the tip artifact commit per convention (artifact job rebuilds it again after merge).
  • Pre-existing, unrelated: test/descriptor.test.mjs's local independentChecksum uses non-BIP380 constants and compares against itself, so its checksum assertions are circular. Not fixed here; this PR's fuzzer does validate real descriptor checksums against the engine, so the gap is covered for the multisig outputs.
  • fuzz-msig added to the artifact and deploy gate lists; the validate.test.mjs pins updated, matching how fuzz-lifehash was gated (d0956f0).

Verified

  • cargo test: 4/4 (BIP67-style multisig wrapped forms, BIP86 vector, sortedmulti_a wallet vector, allocator)
  • npm test (tracked suites): 849 pass / 0 fail (1 pre-existing Edge-absent skip)
  • npm run build && npm run verify; npm run test:browser (Firefox, Chrome)
  • npm --prefix fuzzing run fuzz:msig: 1000 configs / 18,856 checks, 0 mismatches; fuzz:lifehash clean

@portlandhodl
portlandhodl marked this pull request as draft September 1, 2026 16:04
@portlandhodl
portlandhodl force-pushed the feat/miniscript-descriptor-engine branch 2 times, most recently from d4089b0 to 891b23f Compare September 1, 2026 20:12
@portlandhodl
portlandhodl marked this pull request as ready for review September 1, 2026 20:31
@portlandhodl
portlandhodl force-pushed the feat/miniscript-descriptor-engine branch 2 times, most recently from b8a2ebe to 6db3ee5 Compare September 1, 2026 21:14
Add a descriptor engine to entropylab-wasm (new src/descriptor.rs, one new
export: el_desc_derive). rust-miniscript 13.1.0 (exact pin, Cargo.lock)
parses the descriptor — BIP380-386 key expressions, xpub and xprv/WIF
alike, a supplied #checksum verified — and derives the address,
scriptPubKey, and participant keys at a child index. Multipath (<0;1>)
descriptors are refused: one call derives one output, so callers pass a
single branch.

BIP390 musig() key expressions are layered on top of rust-miniscript
(upstream support is still in review: rust-bitcoin/rust-miniscript#954):
participants are derived, KeySort-ed, and aggregated per BIP327, a trailing
/NUM/.../* path derives from the aggregate's BIP328 synthetic xpub, and the
concrete aggregate key is substituted back for rust-miniscript to finish.
Two more upstream gaps are closed in the same pass: rawtr(musig(...)) —
rust-miniscript does not parse rawtr — and taproot sortedmulti_a, which is
derived, sorted as x-only bytes, and rewritten to the multi_a it denotes.
musig() outside tr()/rawtr(), nested musig(), hardened aggregate steps, and
ranged participants under a ranged musig() are all rejected per BIP390.

Crate tests pin the published BIP327 key-aggregation, BIP328 synthetic-xpub
and BIP390 descriptor vectors (valid and invalid), plus a BIP86 vector. The
regenerated artifact (miniscript adds ~540 KB of wasm) is committed per
convention; CI rebuilds it from the Rust sources and tests the fresh build,
so the committed copy cannot hide a broken crate.

Verified: cargo test 9/9; node --test test/wasm-boundary.test.mjs and the
full tracked suite (832 pass, 0 fail); npm run build && npm run verify;
cargo +nightly miri test (allocator suite; the secp256k1 C statics are
Miri-incompatible, as before).
hodlBuildMsig now derives every address from the branch descriptor itself
through the new engine, so the addresses shown cannot drift from the
watch-only descriptor the wallet exports — previously the descriptor text
and the address rows were built by two parallel code paths. hodlMsigAddr
(the address-match look-ahead keeps using it) turns raw co-signer keys into
the same sortedmulti/multi (or sortedmulti_a/multi_a under the BIP341 NUMS
key) descriptor and lets the crate evaluate it; sorting moves from a JS
byte compare into the descriptor semantics, as BIP67/BIP386 intend.

The co-signer identity defenses are unchanged: the engine returns the
derived participant keys with each address, and the final "no repeated
public key in a script" check stays in place verbatim. Addresses and
descriptors are byte-identical to before — the untouched scure
differential sweep in msig-address-kinds.test.mjs proves it, now against
the engine path. One internal shape change: the address-row scriptHex now
carries the scriptPubKey (nothing in the UI ever displayed the old value).

Verified: npm test (tracked suites) 832 pass / 0 fail; npm run build &&
npm run verify; test:browser (Firefox, Chrome).
A co-signer paste like [fd802fac/48h/0h/0h/2h]Zpub…/0/1/0 died as "Invalid
Base58Check string" on older builds. The parser now also strips a lone
trailing slash, while the branch-wildcard forms (/0/*, /<0;1>/*) stay
decoration and a trailing numeric path (xpub…/0/20) remains honored
descriptor key derivation — the co-signer's public keys derive through it,
and the exported descriptor carries it before the branch step. The multisig
intro text says so.

New fuzzer (fuzzing/msig/, wired as the fuzz-msig CI gate alongside
fuzz-lifehash): the app's real filter/parser/descriptor templates are fuzzed
against an independent @scure/bip32 + @scure/btc-signer stack across the
option permutations (script kind x standard x key order x network x m-of-n x
branch/address windows). It asserts decorated pastes yield byte-identical
tokens, derivation paths, descriptors, and addresses; that an honored path
lands in the exported token with the engine matching the reference stack at
every sampled path/branch/index; and that every emitted descriptor's checksum
verifies in the rust-miniscript engine.

Verified: npm --prefix fuzzing run fuzz:msig (1000 configs, 0 mismatches);
npm test (tracked suites); npm run build && npm run verify.
Edit input hands the wallet's saved co-signer fields to MS Station so a
policy can be tweaked and re-derived without re-pasting. The behavior landed
on rock with hodlEditMsigInputs; this adds the missing browser-level
regression test: derive from a paste carrying a /0/* descriptor suffix, open
Edit input, and the lab shows the exact original paste (suffix included)
while the committed wallet keeps its result.

Verified: npm run test:browser (Firefox, Chrome); npm test (tracked suites).
…ers in the co-signer filter

The key-origin purpose now selects the multisig script type for the
singlesig BIPs: m/44h/… is Legacy P2SH, m/49h/… Nested SegWit, m/84h/…
Native SegWit — their depth-3 account keys are accepted as co-signers
(generic xpub/tpub), the script type and Purpose fields auto-decode on paste
and on Edit-input restore, and a mismatched selection errors with a pointer.
An unrecognized purpose stays a custom one, as before. The four-step
BIP48-style form of these paths keeps working unchanged.

The co-signer field filter now keeps the multipath characters < > ; —
stripping them mangled a pasted /<0;1> into /01, and with trailing numeric
paths honored that could silently derive through a made-up path instead of
failing loudly. The injection suite keeps its teeth: the payload is asserted
never to execute (a textarea value is inert text). A valid paste's hint notes
when branches and indexes derive below an honored trailing path, and the
intro text describes both rules.

fuzzing/msig gains an honored-path leg: a numeric trailing path must land in
the exported token, with branch descriptors and the multipath export below
it, and the engine matching the scure reference at every sampled
path/branch/index — plus pinned guard vectors for the
xpub/0/0/20 -> xpub/0/0/20/<0;1>/* formula.

Verified: npm --prefix fuzzing run fuzz:msig (0 mismatches); npm test
(tracked suites); npm run build && npm run verify; test:browser (Firefox,
Chrome).
A co-signer paste like [fd802fac/48h/0h/0h/2h]Zpub…/0/0/20/* — a signer
export carrying a deep fixed path — kept only the steps before the last
one: the step ahead of /* was read as the branch marker and rebuilt, so
the exported descriptor always read /<0;1>/* no matter how deep the
supplied path ran. Now the wildcard strips only a SOLE branch step
(/0/*) or the multipath form (/<0;1>/*); two or more steps ahead of it
are honored in full: branches and indexes derive below them, and the
multipath export lands below them too — xpub/0/0/20/* exports as
xpub/0/0/20/<0;1>/* (valid BIP389: a multipath step is legal at any
position). A sole bare step still derives through itself (xpub…/1, the
key-reuse flow), and hardened honored steps keep the existing clear
rejection; public derivation cannot supply them.

The parser tokenizes the trailing path instead of regex-stripping it: a
wildcard is only legal as the last step, a multipath step is only legal
as the last non-wildcard step and may follow only plain number steps,
and anything else fails loudly instead of being mangled into the key.
BIP45 cosigner-step handling is unchanged: the 45h purpose key's first
trailing step is the cosigner branch, decoration like the branch marker.
Honored steps are canonicalized (a pasted /01 exports as /1).

msig-key-derivation-path.test.mjs changes its /1/0/* expectation from
"1" to "1/0" on purpose, and says so in the test: the deep tail is
preserved rather than read as a branch suffix. The fuzzer's honored leg
now sometimes carries the wildcard on multi-step paths, with a pinned
guard that /0/0/20 and /0/0/20/* honor identically; the browser test
pastes the full signer form again.

Verified: fuzz:msig 1000 configs / 18,856 checks, 0 mismatches; npm test
(tracked suites) 851 pass / 0 fail (1 pre-existing Edge-absent skip);
npm run build && npm run verify; test:browser (Firefox, Chrome).
Removes the crate's musig() layer — BIP327 KeySort/KeyAgg, BIP328
synthetic-xpub derivation, and the rawtr(musig()) template handling —
along with the BIP327/328/390 vector tests on the Rust side and the JS
boundary, and the README mentions. Descriptor evaluation keeps everything
the app uses: BIP380-386 via rust-miniscript, the taproot sortedmulti_a
rewrite (derived, sorted as x-only bytes, rewritten to multi_a), #checksum
verification, and multipath refusal. A descriptor containing musig() now
fails loudly in rust-miniscript's parser. The wasm artifact sheds ~17 KB
of base64. The support can return when upstream lands it
(rust-bitcoin/rust-miniscript#954).

Verified: cargo test 4/4; npm test (tracked suites) 849 pass / 0 fail;
fuzz:msig 1000 configs / 18,856 checks, 0 mismatches; fuzz:lifehash
clean; npm run build && npm run verify; test:browser (Firefox, Chrome).
@portlandhodl
portlandhodl force-pushed the feat/miniscript-descriptor-engine branch from 6db3ee5 to 929a4a3 Compare September 1, 2026 21:16
@portlandhodl portlandhodl changed the title Multisig over rust-miniscript: WASM descriptor engine (BIP390 musig), descriptor-derived wallets, co-signer suffix tolerance Multisig over rust-miniscript: WASM descriptor engine, descriptor-derived wallets, co-signer trailing-path preservation Sep 1, 2026
@w-s-bitcoin
w-s-bitcoin merged commit c40d8c4 into rock Sep 1, 2026
@portlandhodl
portlandhodl deleted the feat/miniscript-descriptor-engine branch October 2, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants