Repository navigation
Multisig over rust-miniscript: WASM descriptor engine, descriptor-derived wallets, co-signer trailing-path preservation - #244
Merged
Conversation
portlandhodl
marked this pull request as draft
September 1, 2026 16:04
portlandhodl
force-pushed
the
feat/miniscript-descriptor-engine
branch
2 times, most recently
from
September 1, 2026 20:12
d4089b0 to
891b23f
Compare
portlandhodl
marked this pull request as ready for review
September 1, 2026 20:31
portlandhodl
force-pushed
the
feat/miniscript-descriptor-engine
branch
2 times, most recently
from
September 1, 2026 21:14
b8a2ebe to
6db3ee5
Compare
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
force-pushed
the
feat/miniscript-descriptor-engine
branch
from
September 1, 2026 21:16
6db3ee5 to
929a4a3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
One multisig overhaul, rebased onto current
rock:el_desc_derive, newentropylab-wasm/src/descriptor.rs). rust-miniscript 13.1.0 (exact pin, committedCargo.lock) parses descriptors — BIP380–386 key expressions, xpub and xprv/WIF alike, a supplied#checksumverified — and derives the address, scriptPubKey, and participant keys at a child index.hodlBuildMsigderives 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 intosortedmulti/sortedmulti_adescriptor semantics. Co-signer identity defenses unchanged — the engine returns the derived participant keys and the final no-repeated-key check stays verbatim.[fd802fac/48h/0h/0h/2h]Zpub…/0/1/0used to die as "Invalid Base58Check string". Now a trailing numeric path is honored in full as descriptor key derivation — a barexpub…/1(the key-reuse flow), a deepxpub…/0/0/20, and a deep signer export with the branch wildcardxpub…/0/0/20/*alike; the exported descriptor carries every step before the branch step (xpub/0/0/20/*exports asxpub/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 (/01exports 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)
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…/1reuse). 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.mjsnow expects/1/0/*→1/0(deep tail preserved) instead of1; the test comment says why.rockvia Restore folder tabs, Edit input memory, and LifeHash rename #237 (hodlEditMsigInputs/hodlFillMsigLabFromWallet).b82af0ffixed in passing:descriptor.rscallsnetwork_from_selector, and the crate's own test module + doc comments referenced the pre-renamesecp_alloc/secp_free(brokecargo testonrockitself — CI only builds the crate).Scope notes
Taproot
sortedmulti_ais layered on top of rust-miniscript in the crate (derived, sorted as x-only bytes, rewritten to themulti_ait 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 BIP390musig()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 containingmusig()fails loudly in rust-miniscript's parser. It can return once upstream ships it.Notes
msig-address-kinds.test.mjs, now exercising the engine path.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.jsregenerated and committed per convention (CI rebuilds from the Rust sources and tests the fresh build). Artifact grows ~520 KB of wasm for miniscript;entropylab.htmlrebuilt in the tip artifact commit per convention (artifact job rebuilds it again after merge).test/descriptor.test.mjs's localindependentChecksumuses 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-msigadded to theartifactanddeploygate lists; thevalidate.test.mjspins updated, matching howfuzz-lifehashwas 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:lifehashclean