Skip to content

Add BIP390 musig() descriptor key expressions - #954

Open
starius wants to merge 6 commits into
rust-bitcoin:masterfrom
starius:musig2
Open

Add BIP390 musig() descriptor key expressions#954
starius wants to merge 6 commits into
rust-bitcoin:masterfrom
starius:musig2

Conversation

@starius

@starius starius commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds descriptor support for the BIP390 musig() key expression in Taproot descriptors and Tap Miniscript key positions.

This addresses rust-bitcoin/rust-miniscript#182.

The implementation covers:

  • parsing function-style descriptor key expressions such as musig(A,B)/0/*;
  • representing musig() as a first-class DescriptorPublicKey variant;
  • validating BIP390 shape and derivation constraints;
  • deriving BIP327 aggregate public keys;
  • deriving aggregate-level paths through the BIP328 synthetic xpub;
  • parsing descriptors with secret musig() participants;
  • exposing descriptor-facing MuSig helper APIs for wallet integrations;
  • testing valid BIP390 scriptPubKey vectors and invalid placement rules.

🤖 Generated with Codex, cross-validated with Claude

starius added 6 commits May 17, 2026 23:56
Teach the expression tree to preserve derivation suffixes after
function-style key nodes, such as musig(A,B)/0/*.

The parser records postfix data on the node that owns the closing
parenthesis. Key-expression callers now serialize and parse the whole
subtree instead of requiring every key to be terminal text.

Taproot internal keys and Tap miniscript key arguments use these
helpers. Non-key consumers reject postfixes through the standard node
validation helpers, including threshold parsing, so Miniscript fragments
cannot silently accept key-derivation syntax.
Add DescriptorPublicKey::Musig and DescriptorMusigKey so descriptors can
retain participant keys, aggregate-level derivation, and wildcard state.

Route construction through DescriptorMusigKey::new. That keeps the
parser's BIP390 invariants attached to the type itself: no empty or
nested aggregates, no invalid aggregate-level derivation, no hardened
aggregate derivation, and no mismatched participant multipath lengths.

BIP390 forbids empty aggregates but does not require at least two
participants. Keep musig(K) valid to match Bitcoin Core's musig()
descriptor behavior.

Extend the DescriptorPublicKey helper surface to the new variant so
musig() round-trips and can participate in descriptor derivation before
aggregate key computation is wired in. The network helper distinguishes
mixed participant xpub networks, and musig() reports no synthetic master
fingerprint.

Context helpers describe the aggregate key shape, so uncompressed
participant KEYs do not make musig() itself an uncompressed key.
Implement the descriptor-facing subset of BIP327 key aggregation and
BIP328 synthetic xpub derivation in a private descriptor::musig module.

Definite musig() keys derive participant keys, sort them as BIP390
requires, aggregate them with BIP327 coefficients, and apply
aggregate-level unhardened derivation through the BIP328 synthetic xpub
when needed.

Document BIP327's infinity-aggregate failure as a panic because
derive_public_key is infallible today. The failure remains
cryptographically unreachable for non-adversarial participants.

Tests pin BIP328 aggregate/xpub vectors, synthetic xpub metadata,
x-only participant lifting, uncompressed participant compression, and
BIP390 scriptPubKey vectors including duplicated participants with
aggregate derivation.
Add public helpers that let wallet integrations inspect how a musig()
key expression derives, walk participant leaf keys, and derive
participant keys at an index.

Expose fallible aggregate-key and final-key derivation through
DescriptorMusigKey. DefiniteDescriptorKey derivation remains infallible,
but callers can now handle BIP327 key-aggregation failure explicitly.
Make Descriptor::parse_descriptor recurse into musig() participant
expressions instead of treating the aggregate as one opaque key string.

Each secret participant is converted to its public descriptor key and
inserted into the KeyMap individually. There is no aggregate secret key
to store. Nested musig() expressions are rejected before the recursive
secret-key walk so malformed input does not repeatedly reparse nested
aggregate strings.

to_string_with_secret reconstructs the musig() expression with secret
participants when the KeyMap has them. It renders the aggregate
derivation suffix from DescriptorMusigKey directly, preserving
round-trip serialization without reparsing the public key string.

Tests cover BIP32 xprv and WIF participants because those pass through
different DescriptorSecretKey parsing paths.
Add descriptor-level coverage for musig() in Taproot script key
positions. The tests exercise pk(musig(...)), multi_a(..., musig(...)),
and sortedmulti_a(..., musig(...)), including a ranged aggregate
derivation case.

Add invalid placement coverage for legacy and Segwit v0 contexts,
top-level musig(), musig() as a Taproot script branch, and descriptor
level nested musig(). This keeps musig() accepted only as Taproot key
material.

These tests document the intended BIP390 boundary: musig() is a key
expression for Taproot descriptors, not a replacement for legacy
public-key expressions or Miniscript fragments.
@tcharding

Copy link
Copy Markdown
Member

Can you trim that PR description down, it smells a lot like LLM slop? (I did not read it FTR)

@starius

starius commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

Can you trim that PR description down, it smells a lot like LLM slop? (I did not read it FTR)

Done.

I didn't know LLM usage has to be disclosed here. Good to know now, I'll take this into account. I used LLM heavily when working on this: code, tests, commits, bitcoin core compatibility verification, etc.

@apoelstra

Copy link
Copy Markdown
Member

I'm gonna say concept-NACK on this inline implementation of key aggregation. Though it is kinda a cool workaround for "we don't have a version of rust-secp that supports musig and is supported by rust-bitcoin"..

The resulting code is just too big, hard to verify and hard to maintain.

Instead we need to get rust-secp 0.29.x to support musig somehow.

@starius

starius commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

@apoelstra I agree. Crypto primitives should live in the secp256k1 crate, not here.

The current stable version of secp256k1 is 0.31.1, which does not have MuSig2.
The beta version is 0.32.0-beta.2, which does have MuSig2.
rust-miniscript is using secp256k1 0.29.x.

What is the plan? Wait for the 0.32.0 release, then backport MuSig2 support to 0.29.x, then switch to that version here and implement musig() support using the crypto primitives provided by secp256k1, right?

@apoelstra

Copy link
Copy Markdown
Member

Roughly, yes. The backport is not trivial because there are C linker issues and semver issues to navigate.

portlandhodl added a commit to OogaBoogaX/entropylab that referenced this pull request Sep 1, 2026
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).
portlandhodl added a commit to OogaBoogaX/entropylab that referenced this pull request Sep 1, 2026
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).
portlandhodl added a commit to OogaBoogaX/entropylab that referenced this pull request Sep 1, 2026
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 added a commit to OogaBoogaX/entropylab that referenced this pull request Sep 1, 2026
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).
portlandhodl added a commit to OogaBoogaX/entropylab that referenced this pull request Sep 1, 2026
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).
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.

3 participants