Skip to content

smite: fuzz the simple taproot channel funding flow - #210

Open
erickcestari wants to merge 16 commits into
lnfuzz:masterfrom
erickcestari:simple-taproot-channels
Open

smite: fuzz the simple taproot channel funding flow#210
erickcestari wants to merge 16 commits into
lnfuzz:masterfrom
erickcestari:simple-taproot-channels

Conversation

@erickcestari

Copy link
Copy Markdown
Contributor

Adds option_simple_taproot support, from the wire types up to driving a full
taproot funding flow against a target and asserting what comes back.

The IR could already generate a taproot channel_type, but the funding output
was always P2WSH, the commitment always segwit v0, and the MuSig2 TLVs did not
exist, so a target negotiating one never saw a message it could act on.

  • Wire types for the 66-byte MuSig2 nonce and 98-byte partial signature.
    Opaque byte containers: a malformed nonce must decode, so smite reports it as a
    finding instead of failing to parse the message.
  • BIP 327 helpers (key aggregation with the BIP 86 tweak, nonce derivation,
    partial signing/verification). Sole owner of the musig2 crate, which pulls its
    own secp256k1, so keys cross that boundary as bytes.
  • TLVs: next_local_nonce (4) on open_channel/accept_channel/
    channel_ready, partial_signature_with_nonce (2) on funding_created/
    funding_signed. The latter two had no TLV stream at all, silently dropping
    trailing bytes.
  • Funding output: single P2TR keyed on the MuSig2 aggregate, already BIP 86
    tweaked by aggregation, so it is not tweaked again.
  • Commitment: all outputs P2TR, to_local/to_remote under the NUMS point,
    anchors keyed on the main output key since MuSig2 no longer reveals the funding
    key. Spends via the key path, so BIP 341 digest and a partial signature. Weight
    968, not 1124, which changes the fee both peers must agree on.
  • Executor: nonce published in open_channel, partial signature in
    funding_created (fixed signature field zeroed), funding_signed verified
    against the published nonce, fresh nonce in channel_ready. Distinct derivation
    context per nonce, since reuse across sessions leaks the funding key.
  • Oracle: accept_channel flags a target that accepts without a usable nonce,
    answers without publishing one, or announces the channel.

Coverage report Eclair:

eclair-output-cov-html.tar.gz

Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
@erickcestari

Copy link
Copy Markdown
Contributor Author

I'll make this PR draft for now since it can be refactored using the Features bit primitives of #192

@erickcestari
erickcestari marked this pull request as draft August 19, 2026 13:40
Simple taproot channels carry a 66-byte MuSig2 public nonce and a 98-byte
partial signature paired with the nonce that produced it.

Both are opaque byte containers rather than parsed points. Decoding must not
reject a malformed nonce: a target that sends one is violating the spec, and
smite reports that as a finding instead of failing to decode the message.
Key aggregation with the BIP 86 taproot tweak, nonce derivation, partial
signing and partial signature verification, for the taproot funding output
and commitment signatures.

This is the only module that talks to the musig2 crate. That crate pulls its
own secp256k1 version, distinct from the one bitcoin re-exports, so keys
cross the boundary as serialized bytes and the rest of smite only ever sees
bitcoin::secp256k1 types.

Nonces are derived deterministically from the signer's funding pubkey and a
per-use context rather than from the OS RNG. Seeding from public data would
be unacceptable in a real node, but smite runs under Nyx snapshots where a
crash must replay from the same input, and it holds throwaway keys and no
funds. It also lets the first nonce be derived in open_channel, where only
the funding pubkey is in scope.

Key aggregation is checked against the funding test vector in
bolt-simple-taproot.md.
next_local_nonce (type 4) on open_channel, accept_channel and channel_ready,
and partial_signature_with_nonce (type 2) on funding_created and
funding_signed.

funding_created and funding_signed had no TLV stream at all, so trailing
bytes were silently dropped; they now decode one. Both TLV types are even,
so each message that accepts one must whitelist it as known, and
channel_ready's stream had to move off the reject-all-even path.

Nothing emits these yet. The executor and oracle changes here only keep
their fixtures compiling against the new fields.
@erickcestari
erickcestari force-pushed the simple-taproot-channels branch from d59e0b2 to 97a57a2 Compare August 20, 2026 13:47
The commitment format follows from the bits set in the negotiated
channel_type. Name that mapping in two predicates: is_simple_taproot on
ChannelConfig, which the funding output is about to need as well, and
has_anchor_outputs for the commitment itself.

has_anchor_outputs is separate from the option_anchors bit because simple
taproot channels carry anchors without setting the anchor bits.

The commitment tests build their channel_type from Features rather than raw
byte vectors while here.
A simple taproot channel funds a single P2TR output keyed on the MuSig2
aggregate of both funding pubkeys, instead of a 2-of-2 P2WSH.

build_funding_transaction and matches_funding_output take the channel_type
and pick the format from it. The aggregate key is already BIP 86 tweaked by
key aggregation, so the output assumes it is tweaked rather than tweaking
again, which would produce a key neither peer can sign for.

Deriving the key can fail in principle, so funding construction gains an
error type rather than panicking on fuzzer-chosen keys.

Nothing selects taproot yet: the IR does not carry the channel type into
CreateFundingTransaction, so the executor still asks for P2WSH.

The output script is checked against the funding test vector in
bolt-simple-taproot.md.
Every output becomes P2TR: to_local and to_remote commit to a tapscript tree
under the NUMS point so the script path is always taken and the keys inside
are revealed on chain, and the anchors key on the party's main output key
rather than the funding key, which MuSig2 no longer reveals.

The commitment spends the funding output through the taproot key path, so
the digest is the BIP 341 one over the funding prevout rather than BIP 143
over a witness script, and the signature becomes a MuSig2 partial signature.
ChannelState carries the nonce we gave the counterparty to sign against, so
the incoming funding_signed can be verified.

Taproot commitments weigh 968 rather than the 1124 of segwit v0 anchors,
which changes the fee both peers must agree on.

The scripts, tapscript root and output keys are checked against the test
vectors in bolt-simple-taproot.md, and a partial signature is round-tripped
between both sides of the same session.
The funding output format follows from the negotiated channel type, so the
operation that builds the funding transaction has to know it. It becomes a
fifth input rather than being inferred, keeping the dependency visible in
the program listing and leaving every mutator working unchanged.

It must match the type given to BuildOpenChannel; when a mutator makes them
disagree the target simply never sees the funding output it negotiated.

This changes the operation's arity, and the executor asserts arity exactly,
so existing corpora must be regenerated.
open_channel publishes the MuSig2 nonce the peer signs our first commitment
against, funding_created zeroes the fixed signature field and carries a
partial signature instead, funding_signed is verified against the nonce we
published, and channel_ready publishes a fresh nonce to replace the one the
funding flow consumed.

Each nonce gets a distinct derivation context, since reusing one across two
signing sessions would leak the funding key.

channel_flags is left exactly as the program specifies, including the
announce_channel bit that taproot channels must not set, so mutators can
still exercise how targets reject a public taproot channel. A missing or
malformed peer nonce is likewise left to the oracles rather than treated as
an executor error: it means the peer did not sign what we asked it to.
A target that accepts an open_channel negotiating a taproot channel without
a usable MuSig2 nonce, or one that announces the channel, has skipped a
check the spec requires it to make. So has one that answers without
publishing a nonce of its own.

Also drops a stale note about the commitment fee: the initial commitment
check now uses the 968 taproot weight, since CommitmentCost derives the
weight from the channel type.
Taproot channels cannot be gossiped, so a randomly chosen channel_flags
leaves the happy path unreachable for a third of the channel types the
funding flow generator picks from.

Clear the announce_channel bit for those types. Mutators can still flip it
afterwards, which is what exercises how targets reject a public taproot
channel.
lnd v0.21.1-beta gates option_simple_taproot behind
--protocol.simple-taproot-chans. Without it lnd clears bits 80/81 from its
init and answers a taproot channel_type with "requested channel type not
supported", so the taproot funding flow can never be exercised.

Note that lnd master inverted this to an opt-out --protocol.no-taproot-chans,
so this flag will need revisiting when the pinned version moves.
@erickcestari
erickcestari force-pushed the simple-taproot-channels branch from 97a57a2 to 9feb173 Compare August 20, 2026 14:23
@erickcestari
erickcestari marked this pull request as ready for review August 20, 2026 14:23
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