Skip to content

bolt-simple-taproot: fix the output key derivations, signature rules and test vectors - #1352

Open
erickcestari wants to merge 15 commits into
lightning:masterfrom
erickcestari:bolt-simple-taproot-fixes
Open

bolt-simple-taproot: fix the output key derivations, signature rules and test vectors#1352
erickcestari wants to merge 15 commits into
lightning:masterfrom
erickcestari:bolt-simple-taproot-fixes

Conversation

@erickcestari

Copy link
Copy Markdown
Contributor

Corrections to the Simple Taproot Channels extension, found by re-deriving every
published value from the seed and checking the document against the BIP 340,
341, 342 and 327 reference code and against lnd and eclair.

No protocol change is intended: every fix moves the document toward what the two
implementations already do.

Output keys and control blocks

  • Five output key formulas (anchors, offered and accepted HTLCs, both second
    level outputs) added the tweak hash straight to the internal key. BIP 341
    computes Q = P + int(hashTapTweak(...))G.
  • BIP 341 consumes an internal key in its 32-byte x-only form, but only the NUMS
    point said so. x_only() is now defined once and used for every internal key,
    including in the control block serialization.
  • taproot_nums_point was used throughout the commitment sections but never
    defined, and the to_remote section defined its own value for it, which
    matched neither the definition earlier in the document nor the test vectors.
    Its control block also named combined_funding_key as the internal key.
  • The to_local control blocks carried the wrong data and the delay path claimed
    to be 33 bytes when it needs the sibling leaf hash.
  • The anchor outputs' key path spend was undocumented.
  • Both second level outputs pay to the very same script as to_local, but it was
    called htlc_success in one place and htlc_timeout in the other, reading as
    three distinct scripts. An unclosed code fence in the to_local section had
    also swallowed the revoke script into the delay script's block.

Signatures and nonces

  • commitment_signed did not say which sighash type the HTLC signatures use.
    They are SIGHASH_SINGLE|SIGHASH_ANYONECANPAY, so the receiver has to append
    the 0x83 byte to the 64-byte field when building the witness.
  • SIGHASH_SINGLE commits to the output at the same index as the signed input,
    which constrains how second level transactions may be aggregated.
  • A BIP 327 secret nonce is 97 bytes, not two scalars.
  • Names the HMAC arguments in the counter based nonce derivation, documents
    next_closee_nonce, and fixes the BIP 327 link.

Test vectors

  • The accepted_htlc_* entries carried the local and remote HTLC keys in the
    wrong positions, disagreeing with the script templates here, with the
    transaction vectors in this same document, and with both implementations.
  • The *_remote_commit entries were byte-identical copies of the local ones, so
    they exercised nothing. They are now derived from remote_per_commit_point
    and local_revocation_basepoint.
  • combined_key actually held the BIP 86 tweaked output key, so it is split into
    combined_funding_key and funding_key.

Verification

Every key, script, leaf hash, tapscript root and output key re-derived from the
seed; every signature checked against the BIP 341 sighash using the BIP 340 and
BIP 327 reference implementations, including a full MuSig2 replay of each
commitment signature; every witness executed under BIP 342 rules.

Both implementations ship a copy of these vectors, byte-identical to each other
and, in their transactions section, to this document. Running each project's
own suite against the JSON from this branch:

  • eclair's SimpleTaprootCommitmentsTestVectorSpec passes with no source
    changes. It re-derives the keys, rebuilds all three commitment transactions
    and every HTLC transaction, reproduces remote_partial_sig, and runs
    correctlySpends on each. It reads only params, scripts.funding and
    transactions, so the script entries are covered indirectly, through the leaf
    scripts embedded in the resolution witnesses.
  • lnd's TestTaprootVectors passes on params, the three transactions, the
    signature verification, the MuSig2 replay and 5 of the 11 script entries. The
    6 failures are the changes above: three are lnd's vector generator, and three
    are the combined_key and second level leaf renames its test structs would
    follow.

The 15 commits are split for review only, one defect each, so they can be read
against the BIP text one at a time and any single one can be dropped without
disturbing the rest. They might be squashed into fewer commits before landing.

I've also asked claude to write a script to verify if the new test vectors are correct.

https://gist.github.com/erickcestari/8cd853821529c57aaf6109ee77409dd8

`taproot_nums_point` was used throughout the commitment transaction
sections but never defined anywhere; the point it refers to is defined as
`simple_taproot_nums`. Use that name everywhere.

The `to_remote` section also defined its own value for the NUMS internal
key, `0245b181...`, which disagrees with the value given under "Nothing Up
My Sleeves Points", with the `to_remote.internal_key` in the test vectors,
and with both lnd and eclair. Drop the local redefinition so the single
definition earlier in the document applies.

The `to_remote_control_block` named `combined_funding_key` as the internal
key for the same output. A control block commits to the output's internal
key, which is the NUMS point here, so the block as written would not
validate. The design overview named `combined_funding_key` for that output
as well. Point it at `simple_taproot_nums` too: an unspendable internal key
is what stops the remote party from bypassing the 1 block CSV delay.

Also state explicitly that a control block carries the 32-byte x-only
serialization, since the value is quoted in 33-byte compressed form where it
is defined.
The `to_local` tapscript tree has two leaves, so a control block spending
either path must carry an `inclusion_proof`: the `tap_leaf` hash of the
path not taken, as the HTLC sections already phrase it. Both blocks instead
concatenated the revealed script itself, and the delay path was described
as `33` bytes when a two-leaf tree makes it `65`.

Verified against the test vectors: `TapBranch(sort(8fcd64..., dbf0400e...))`
is the published `to_local.tapscript_root`.

Also drop the claim that a breach sweeps "the top-level key spend path".
The internal key is a NUMS point, so no key path spend exists; the breach
sweep is the script path spend described immediately below it.
The fence opened for `to_delay_script` was never closed, so the
`revoke_script` bullet that follows was rendered as part of the code block.
Only the anyone-can-spend script path was described, but the anchor commits
to a real internal key rather than a NUMS point, so its owner can sweep it
immediately without waiting 16 blocks. Both lnd (`TaprootAnchorSpend`) and
eclair (`Taproot.anchor`) rely on that path.
`next_closee_nonce` is required by the `closing_sig` requirements and
implemented by lnd, but was missing from the list of new TLV types. It also
reuses type `22` with a different payload from `next_local_nonces`, which
contradicts the note that these type IDs are always the same.

Since type `22` is now ambiguous on its own, annotate both entries with the
messages they appear in. `next_local_nonces` is defined for `revoke_and_ack`
as well as for `channel_reestablish`, and lnd carries it in both under the
same type (`LocalNoncesRecordTypeDef`), so name both.

The JIT nonce summary also said `closing_sig` carries no nonce at all,
contradicting both the message definition and its requirements.
The first argument was called `msg` but is used as the HMAC key: lnd keys
the HMAC with the `taproot-rev-root` tag and passes the revocation root
hash as the message. Name both arguments so the order is unambiguous.
The funding vector's `combined_key` held the BIP 86 tweaked output key, not
the `KeyAgg` result the specification calls `combined_funding_key`. Publish
both under the names the specification uses.
Both leaves of the `accepted_htlc_*` script vectors carried the local and
remote HTLC keys in the wrong positions, disagreeing with the script
templates in this document, with lnd (`ReceiverHtlcTapLeafSuccess`,
`ReceiverHtlcTapLeafTimeout`) and with eclair (`receivedHtlcSuccess`,
`receivedHtlcTimeout`).

The transaction vectors in this document already disagreed with them.
Recovering the sibling leaf from the resolution transactions' control
blocks yields the timeout leaf keyed by `<remote_htlcpubkey>`, and the
corrected entry produces pkScript `51209ce82cd1...`, which is exactly
output 2 of the five-HTLC commitment (1000 sat, incoming, expiry 500). The
previous entry matched no output in any of the transactions.

The `*_remote_commit` entries were byte-identical copies of their
`*_local_commit` counterparts, so they exercised nothing: the published
parameters carried only `local_per_commit_point` and
`remote_revocation_basepoint`, which are the local commitment's inputs.

Add `local_revocation_basepoint` and `remote_per_commit_point` under the
existing `SHA256(seed || label)` convention, along with the three keys they
derive, and regenerate the two entries from them.

Two things change on the remote commitment, both per lnd
`lnwallet/commitment.go`: `DeriveCommitmentKeys` takes the revocation
basepoint from the local channel config when the commitment is the remote
party's, and `GenTaprootHtlcScript` swaps the sender and receiver
arguments, so an HTLC we offer is an offered HTLC on our commitment and an
accepted one on theirs.

Leaf hashes, tapscript roots, output keys and pkScripts recomputed.
The link to the musig2 BIP was missing its closing parenthesis, so it
rendered as literal text, and the sentence opened with a typo.
A BIP 327 secnonce is 97 bytes: NonceGen appends the signer's public key
to the two scalars, and Sign refuses a secnonce whose trailing key is not
the one it is signing with. The test vectors already publish 97-byte
secret nonces, so only this section was short.
The sighash type of the second level signatures was only stated much
later, in the HTLC Second Level Transactions section, while the packing
rule here says the signature is 64 bytes. BIP 342 requires 65 bytes for
any sighash type other than SIGHASH_DEFAULT, so a receiver has to know
that it must append the 0x83 byte when it builds the witness.
Second level transactions are meant to be aggregated with each other and
a fee bumping input, but BIP 341 commits SIGHASH_SINGLE to the output at
the same index as the signed input. Aggregating them without preserving
that pairing silently invalidates the pre-signed signatures.
Both second level outputs pay to the very same script the to_local output
uses, but they called it `htlc_success` and `htlc_timeout`, which reads as
if there were three distinct scripts. The test vectors show they are one:
both entries have leaf hash dbf0400e..., as does `to_local`'s settle leaf.
Five output key formulas added the tweak hash straight to the internal
key, which adds a scalar to a point. BIP 341 computes the output key as
Q = P + int(hashTapTweak(...))G, which is what the other formulas in this
document, and the test vectors, already do.
BIP 341 consumes an internal key in its 32-byte x-only form, both inside
the TapTweak hash and in the control block, but only the NUMS point said
so. Every other internal key here is written in its 33-byte compressed
form, including in the test vectors, so name the conversion and state the
rule once for all of them.
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.

1 participant