Skip to content

Conversation

@Roasbeef
Copy link
Collaborator

This PR puts forth two concepts:

  1. The concept of an "extension BOLT", which is a single documentation extension to the base BOLT spec that references existing "base" BOLTs with new slightly modified functionality. This presents an alternative to littering the main set of BOLTs with a series of "if" statements, which can be somewhat unwieldy for larger changes, and also harder to review/parse.
  2. A new set set of feature bit, channel type, funding output changes, commitment changes, and HTLC script changes under the umbrella of "simple taproot channels", a.k.a the minimal amount of changes needed to get us to "taprooty level 1".

The extensions described in this document have purposefully excluded any gossip related changes, as the there doesn't yet appear to be a predominant direction we'd all like to head in (nu nu gossip vs kick the can and add schnorr).

Most of the changes here described are pretty routine: use musig2 when relevant, and create simple tapscript trees to fold in areas where the script has multiple conditional paths. The main consideration with musig2 is ofc: how to handle nonces. This document takes a very conservative stance, and simply proposes that all nonces be 100% ephemeral, and forgotten, even after a connection has been dropped. This has some non-obvious implications w.r.t the retransmission flow. Beyond that, it's mostly: piggy back the nonce set of nonces (4 public nonces total, since there're "two" messages) on a message to avoid having to add additional round trips.

The other "new" thing this adds is the generation/existence of a NUMs point, which is used to ensure that certain paths can only be spent via the script spend path (like the to remote output for the remote party, as this inherits anchor outputs semantics).

This is still marked as draft, as it's just barely to the point of being readable, and still has a lot of clean ups to be done w.r.t notation, clarify, wording, and full specification.

@Roasbeef Roasbeef force-pushed the simple-taproot-chans branch from d7b1fe6 to ec8c7b4 Compare May 30, 2022 17:04
@Roasbeef
Copy link
Collaborator Author

Some things that came up in meatspace discussions:

  • Need to ensure we specify co-op close interaction re needing to check diff types of co-op close transactions (remote party trimmed and output, etc)
  • Maybe we should remove the NUMs point for the to_remote output, and just use the musig2 funding key there: in the best interest of the local party to just never sign for that
  • We may need to actually send the next nonce in the commit_sig message (only one of them?) to ensure that after a commitment dance, both parties are able to immediately send a sig.

@Crypt-iQ
Copy link
Contributor

Crypt-iQ commented May 30, 2022

I think the commit_sig should contain the sender's "remote nonce" and the revoke_and_ack contain the sender's "local nonce".

Also since funding_locked will be sent repeatedly with scid-alias when that is merged and deployed, then there should probably be language to define that the nonces are only sent the first time?

@instagibbs
Copy link
Contributor

let's try to pick naming conventions for nonces that doesn't make me cry over the asymmetry

@ZmnSCPxj
Copy link
Collaborator

ZmnSCPxj commented May 31, 2022

Some points:

This interacts with the 2-of-3 goal of @moneyball . If one participant uses a 2-of-3 and owns ALL 3 keys, then it is fine and we can just have MuSig2 with both channel endpoints. But the 2-of-3 goal is that one channel endpoint is really a nodelet-like setup: there is one sub-participant with 2 keys and another "server" participant with 1 key, a la GreenWallet. This requires composable MuSig2. Now I think composable MuSig2, if it can be proven safe, just requires two Rs just like normal non-composable MuSig2, but we probably need to go pester the MuSig2 authors --- I think they wrote up how composable MuSig2 would work, but only internally and they never actually published the details. This is important because we may need to have variable number of Rs, not just two.

--

This interacts with VLS as well @ksedgwic . The nonce r behind R = r * G needs to be retained, since we pre-send the R and on the actual signing MUCH later we use the r. VLS cannot have the host store r since exfiltration of the r together with a complete R, s implies exfiltration of the private key. But this is a per-channel state and constrained devices might not have enough space for each channel. What could be done would be to put the per-channel states into a Merkle tree and have the VLS constrained device store only the Merkle tree root, then every time the r has to be rotated (at each reconnect or at each signing event) update the Merkle tree root in the constrained persistent storage --- it has to be persistent since the host could keep the connection alive while power-cycling the VLS device. You also have to be careful of "UPDATE IN PLACE is a poison apple" and replicated storage. Now as mentioned we cannot store r directly so what the VLS has to do would be something like: generate random scalar q, compute Q = q * G, compute r = ECDH(k, Q) where k is the private key held by the signer device, and then store Q on the host so it can recover r later without the host being able to recover r as well.

A similar technique may also be useful for the server in the 2-of-3 of @moneyball; rather than maintain a state for each channel of each client, the client could store the per-channel Q that the server generates and uses ECDH to get r and then R = r * G for each channel.

@ZmnSCPxj
Copy link
Collaborator

ZmnSCPxj commented May 31, 2022

So I talked to @jonasnick, and as I understand it, we can work with just two Rs even in the composition case, probably will also work in FROST, maybe. This should be safe but we do not have a written out proof, because it seems the proof is complicated. So at least it looks like we do not need a variable number of Rs, just two from each side should work.

@Roasbeef
Copy link
Collaborator Author

Re recursive musig2: I'm gonna give the implementation a shot (outside the LN context, just the musig-within-musig) just to double check my assumptions re not needing to modify the (revised) nonce exchange flow.

@antonilol
Copy link
Contributor

i made a pull request on this pull request with script fixes

Roasbeef#1

@antonilol
Copy link
Contributor

antonilol commented Jun 2, 2022

Maybe we should remove the NUMs point for the to_remote output, and just use the musig2 funding key there: in the best interest of the local party to just never sign for that

Why not the revocation key? When i publish an old state, the remote party can claim my output and htlcs with the key path, but not his own output, and also has to wait a block. If we set the internal key to the revocation key it will give the remote party more privacy, nobody on chain can see which outputs were to local and to remote (and htlcs if they are swept along). It will also give more consistency with other output as they also have the revocation key as internal key.

it will also be cheaper (or get a higher fee rate with the same amount of sats), this only requires a signature from a taptweaked revocation key (65) instead of a signature (65), the script (36) and the controlblock (34) (incl length prefix)
this will save 70 wu (17.5 vB) (keep in mind this is only applies to revoked commitments, for normal force closes we want to enforce the 1 OP_CSV)

@antonilol
Copy link
Contributor

antonilol commented Jun 2, 2022

#995 (comment) makes it invisible for outside observers to identify the to_remote output in case of a revoked commitment. if there are some htlcs on it that are long expired and the second stage is broadcasted (like in the fee siphoning attack), the funds go to the local delayed pubkey + relative timelock. outside observers can now see which output was the to_local one, just search the output of an htlc 2nd stage tx in the commitment transaction.

example ctx: 15c262aeaa0c5a44e9e5f25dd6ad51b4162ec4e23668d568dc2c6ad98ae31023 (testnet)

the transaction with the expired htlc reveals the to_local output. (it is already revealed by the script, but this wouldnt be the case with a revoked taproot ctx)

this can be fixed by tweaking the local delayed pubkey with the hash of vout of the htlc on the ctx (something you can see on-chain, to make restoring backups easier) and some secret (so only you can do this, not outside observers). this secret can be static across commitments and stored in a static channel backup (this is one way i came up with, but there are of course more ways to change this key and still make restoring backups easy enough)

EDIT: no secret is needed, instead a taptweak like tweak can be done. everywhere where a local delayed pubkey is used, it is tweaked with sha256(pubkey || output index) (or a tagged hash) where output index refers to the output index of the output on the commitment transaction, this way there are no duplicates because there can't be two outputs at the same index.

for clarity: htlc outputs that send funds to the local delayed pubkey use a tweaked local delayed pubkey where the output index of the htlc output on the commitment transaction is used, not the htlc success or timeout tx

@Crypt-iQ
Copy link
Contributor

Crypt-iQ commented Jun 10, 2022

for clarity: htlc outputs that send funds to the local delayed pubkey use a tweaked local delayed pubkey where the output index of the htlc output on the commitment transaction is used, not the htlc success or timeout tx

this would preserve privacy, but you'd also need to do this for the to_local and the local anchor output since if those are claimed, the delayed pubkey is also leaked. if the user doesn't claim their anchor, then only the counter-party would be able to claim their anchor (thereby leaking the local delayed pubkey) rather than anybody with the ability to watch the chain after the 16 CSV has elapsed. The keys could all be tweaked, but then perhaps there is more UTXO bloat if the anchors aren't claimed

@antonilol
Copy link
Contributor

for clarity: htlc outputs that send funds to the local delayed pubkey use a tweaked local delayed pubkey where the output index of the htlc output on the commitment transaction is used, not the htlc success or timeout tx

this would preserve privacy, but you'd also need to do this for the to_local and the local anchor output since if those are claimed, the delayed pubkey is also leaked. if the user doesn't claim their anchor, then only the counter-party would be able to claim their anchor (thereby leaking the local delayed pubkey) rather than anybody with the ability to watch the chain after the 16 CSV has elapsed. The keys could all be tweaked, but then perhaps there is more UTXO bloat if the anchors aren't claimed

hmmm true, so it is either privacy, with no key reuse or no utxo set bloat.

btw another idea about anchors and less utxo set bloat:
with non taproot channels, anchors can always be claimed, because funding keys are used
with taproot, funding keys can also be used
party A and B both have a public key, the funding key becomes P = A * H(H(A || B) || A) + B (musig2 keyagg)
the anchor pubkey for A is A * H(H(A || B) || A) and for B is just its public key B
if one of the anchors is spent outside observers can calculate the other anchor because A = P - B and B = P - A (P = funding key)
the to_local and to_remote can also use these keys to ensure that if neither of the anchors is spent they can be cleaned up but that will cost some privacy

@Crypt-iQ
Copy link
Contributor

P = A * H(H(A || B) || A) + B (musig2 keyagg) the anchor pubkey for A is A * H(H(A || B) || A) and for B is just its public key B if one of the anchors is spent outside observers can calculate the other anchor because A = P - B and B = P - A (P = funding key) the to_local and to_remote can also use these keys to ensure that if neither of the anchors is spent they can be cleaned up but that will cost some privacy

The KeyAgg routine here specifies some tweaking so the aggregation above may not always be the same (https://github.com/jonasnick/bips/blob/musig2/bip-musig2.mediawiki#key-aggregation). I think in your example an observer has a 50% chance of identifying B's funding_pubkey since it isn't tweaked. I am not sure if knowledge of the funding_pubkey actually gives an observer anything as I think these would have sort of an ephemeral nature as they are generated for the funding flow. A user could generate them outside of the funding context (say in their bitcoind wallet and regularly use the key for receiving/sending payments) and use them in the funding flow, but I don't see why a user would do that

@antonilol
Copy link
Contributor

P = A * H(H(A || B) || A) + B (musig2 keyagg) the anchor pubkey for A is A * H(H(A || B) || A) and for B is just its public key B if one of the anchors is spent outside observers can calculate the other anchor because A = P - B and B = P - A (P = funding key) the to_local and to_remote can also use these keys to ensure that if neither of the anchors is spent they can be cleaned up but that will cost some privacy

The KeyAgg routine here specifies some tweaking so the aggregation above may not always be the same (https://github.com/jonasnick/bips/blob/musig2/bip-musig2.mediawiki#key-aggregation). I think in your example an observer has a 50% chance of identifying B's funding_pubkey since it isn't tweaked. I am not sure if knowledge of the funding_pubkey actually gives an observer anything as I think these would have sort of an ephemeral nature as they are generated for the funding flow. A user could generate them outside of the funding context (say in their bitcoind wallet and regularly use the key for receiving/sending payments) and use them in the funding flow, but I don't see why a user would do that

if this is a problem B can tweak the key before using it without A even knowing (also A has to do this because the lexicographically smaller key is tweaked), but i dont think it is, lnd uses a separate bip32 tree for this (separate from the wallet)

(btw without taproot funding pubkeys were revealed every time a channel was closed)

@antonilol
Copy link
Contributor

antonilol commented Jun 13, 2022

The KeyAgg routine here specifies some tweaking so the aggregation above may not always be the same (https://github.com/jonasnick/bips/blob/musig2/bip-musig2.mediawiki#key-aggregation).

afaict the algorithm in this bip is generalized for 32 byte pubkeys and more than 2 signers, the 'simple' musig2 with the pubkey's with the parity bit known looks like this equation i used, btw i got it here https://github.com/t-bast/lightning-docs/blob/master/schnorr.md#musig2

Copy link
Contributor

@instagibbs instagibbs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some old comments I forgot to submit

Most recent comment is noting that partial sigs are 32 bytes, so this needs explicit defining somewhere, since signature types seem to assume 64(may have missed it).

@antonilol
Copy link
Contributor

antonilol commented Jun 27, 2022

#995 (comment)

P = A * H(H(A || B) || A) + B (musig2 keyagg) the anchor pubkey for A is A * H(H(A || B) || A) and for B is just its public key B if one of the anchors is spent outside observers can calculate the other anchor because A = P - B and B = P - A (P = funding key)

nvm this wouldn't work because keys are only revealed when swept without signature

to make this problem somewhat easier i suggest to remove the to_remote_anchor when a to_remote exists, and no longer 1 OP_CSV the to_remote output. the remote party can fee bump using his to_remote output. the only scenario where a to_remote_anchor would be needed is when there is at least 1 non dust htlc attached and the remote party has no (or below dust limit) to_remote output. this can happen when sending a non dust htlc directly after channel opening (the remote party always needs to have the channel reserve as balance, but doesn't have this just after opening)

now that the to_remote_anchor is out of the way (except for 1 case), things get easier
the to_local_anchor's internal pubkey will be local_delayed_pubkey, it is revealed after the csv delay when swept by the local party

this special case can of course be seen from both sides:

  • the local party who opens a channel, sends an htlc an force closes. both parties need an anchor output here. the remote party has no to_remote output because he has no balance and cant fee bump with that. the to_remote_anchor will have the remote_htlc_pubkey as internal key because it is revealed when expired (a second sig is needed there for 2nd stage), and fulfilled
  • (i switch local and remote here) the local party who just got a channel opened by the remote party and sent an htlc. the local party force closes. there is no to_local to reveal the anchor key, so the to_local_anchor's internal key will be the local_htlc_pubkey, the remote party doesn't need an anchor because he has a to_remote output

even more rare: revocation

no anchor keys are revealed here because with the revocation key the taproot key path is used. i don't know to make anchor sweepable in this case

long story short:

Questions/feedback welcome!

Copy link
Contributor

@MPins MPins left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text says that n_A_L is sent in the open_channel message, but in the diagram (taproot_channel_open.jpg) it is sent in the funding_created message.

In this commit, we make a few changes to the local nonces map as
defined:
  * Drop the length prefix, just encode the nonces concatenated to each
    other. The number of nonces to decode can be computed from the size
    of the nonces and a txid.
  * Using the funding txid in the nonce map instead of the empty hash.
@Roasbeef
Copy link
Collaborator Author

@t-bast tacked on this commit to simplify the nonce encoding and use the funding txid instead of the empty hash in the map: 1863746

@t-bast
Copy link
Collaborator

t-bast commented Oct 29, 2025

@Roasbeef is lnd ready for cross-compat tests with the latest changes? Can you point me to a branch that contains everything (and setup instructions to activate taproot and simple close)?

@Roasbeef
Copy link
Collaborator Author

@t-bast here's a branch that has everything combined: https://github.com/Roasbeef/lnd/commits/taproot-interop/.

To activate taproot and rbf close you'll need these arguments/flags:

--protocol.rbf-coop-close
--protocol.simple-taproot-chans

@sstone
Copy link
Collaborator

sstone commented Nov 3, 2025

@t-bast here's a branch that has everything combined: https://github.com/Roasbeef/lnd/commits/taproot-interop/.

To activate taproot and rbf close you'll need these arguments/flags:

--protocol.rbf-coop-close
--protocol.simple-taproot-chans

Hi @Roasbeef I tested that branch at Roasbeef/lnd@5d6551c.
It seems that it is still using a single nonce TLV instead of a nonce map in RevokeAndAck and ChannelReestablish ? If we modify our code to create the map from that single nonce in RevokeAndAck we can then pay and receive payments.

Closing channels (using the "simple close" protocol) only works partially. When closing an eclair<->lnd taproot channel, eclair receives ClosingComplete along with an expected CloserAndClosee sig error from lnd, and can still create a valid closing tx, but lnd gets stuck. This is also true for regular anchor ouput channels on the taproot interop branch which behaves differently from v0.20.0-beta.rc3 (simple close works fine there).

@Roasbeef
Copy link
Collaborator Author

Roasbeef commented Nov 5, 2025

It seems that it is still using a single nonce TLV instead of a nonce map in RevokeAndAck and ChannelReestablish

So in ChannelReestablish the nonces map is used, but we do still set the old nonce field for backwards compat.

As far as RevokeAndAck, my impression was that the nonces map made sense in ChannelReestablish as you need nonces to be able to resume signing for the set of concurrent splices that might exist after a reconnection event. What purpose do the extra nonces serve in RevokeAndAck? As you can only ever revoke a single commitment at a time.

If you look at this commit: 4c1314a, you'll see that the nonce map was only added to ChannelReestablish.


I'll look into the regression for rbf coop close in that interop branch I made, could have been some stray line in the refactor, or the large-ish series of changes that separate that branch from master.

@Roasbeef
Copy link
Collaborator Author

Roasbeef commented Nov 5, 2025

eclair receives ClosingComplete along with an expected CloserAndClosee sig error from lnd,

We'll return that if we expect the ClosingComplete message to set the TLV field where the outputs of both parties are present, but it wasn't in the received message. Will try to pinpoint what's going on here.

@Roasbeef
Copy link
Collaborator Author

Roasbeef commented Nov 5, 2025

@t-bast
Copy link
Collaborator

t-bast commented Nov 5, 2025

As far as RevokeAndAck, my impression was that the nonces map made sense in ChannelReestablish as you need nonces to be able to resume signing for the set of concurrent splices that might exist after a reconnection event. What purpose do the extra nonces serve in RevokeAndAck? As you can only ever revoke a single commitment at a time.

You need new nonces to sign all the pending, active commitments. When a splice is unconfirmed, you have two commitments to sign (that spend a different funding transaction). So you need a map of nonces, a single one won't be enough.

That was updated by @sstone in Roasbeef#8, but it looks like you haven't fully integrated the changes from that PR...maybe we should close it and re-open another PR with a single commit that contains the remaining changes we need in the spec?

@Roasbeef
Copy link
Collaborator Author

When a splice is unconfirmed, you have two commitments to sign (that spend a different funding transaction)

But when a splice is signed, isn't it the case that for those two commitments, a new commit_sig message is sent for each? So then each of those new commits are revoked independently with a distinct revoke_and_ack message?

That was updated by @sstone in Roasbeef#8, but it looks like you haven't fully integrated the changes from that PR...maybe we should close it and re-open another PR with a single commit that contains the remaining changes we need in the spec?

Yeah I gave some feedback on that, but some of my comments were never responded to. It was also based off a dated version of this branch.

Part of my feedback was that it added a lot of context re splicing that would be better done as a follow up. I tried to integrate just the essentials in my last commit, but looks like I missed some details.

I've pushed a new version of the above branch with the following changes:

  • Use the nonce map in revoke_and_add.
  • Fixed the but I introduced when layering the taproot specific logic on top of our existing rbf state machine. I think the old logic wasn't strictly correct.
    • TBH, I still find the section of the spec that describes which of the sig fields to set/validate confusing.
    • Eg: Why would I ever send two sig fields, one of which includes my output while the other omits it?

You can find the new branch here: https://github.com/Roasbeef/lnd/commits/taproot-interop/

Fresh branch, still need to do some testing of my own.

I also need to update this PR to track on that extra commit with the additions for revoke and ack.

We must use the `next_local_nonces` TLV instead of a single nonce TLV
in the following messages to be future-proof with splicing:

- `channel_reestablish`
- `revoke_and_ack`

We must include one nonce for each commitment transaction that can be
published: when there is a pending splice, that means we need one nonce
for the current funding transaction, another nonce for the commitment
transaction that spends the splice transaction, and additional nonces
for each RBF attempt of that splice transaction (if any).

All of those commitment transactions use the same `per_commitment_point`
and are all revoked with the same secret: even though we send multiple
`commit_sig` messages while splices are pending, we send always send a
single `revoke_and_ack` message. This is why it must contain the map of
all next local nonces.
If we want to be future-proof with splicing, we must include the
`funding_txid` in the shachain root, which allows having distinct
shachains for each splice transaction to deterministically derive
local commitment nonces.
The specification for `option_simple_close` was inconsistent with
regards to closee and closer nonce. We fix the incorrect or unclear
requirements.
While `lnd` uses the legacy `closing_signed` mechanism for early
taproot channels support, this shouldn't be in the BOLT: we should
require `option_simple_close` whenever using taproot channels, which
was explicitly designed for taproot.
@t-bast
Copy link
Collaborator

t-bast commented Nov 12, 2025

But when a splice is signed, isn't it the case that for those two commitments, a new commit_sig message is sent for each? So then each of those new commits are revoked independently with a distinct revoke_and_ack message?

We send one commit_sig per active commitment (one for the pre-splice funding transaction, one for the splice transaction, and one for each splice RBF attempt if any), but all of those commitment transactions use the same per_commitment_point: they are thus all atomically revoked with a single revoke_and_ack message.

Yeah I gave some feedback on that, but some of my comments were never responded to. It was also based off a dated version of this branch.

I have opened Roasbeef#9 which contains independent commits that would bring the spec to match what is future-proof with splicing, without including unnecessary splicing extensions. You're right that we should only include the minimal stuff needed for splicing, and will open a separate PR later to add extensions to the splice messages.

I hope it clarifies the remaining work needed to get us to cross-compat!

You can find the new branch here: https://github.com/Roasbeef/lnd/commits/taproot-interop/

Note that the nonce map must also be used in channel_reestablish (see Roasbeef#9) for details. We will resume cross-compat testing whenever you give us the green light!

@sstone
Copy link
Collaborator

sstone commented Nov 12, 2025

Additional info: I tested against https://github.com/Roasbeef/lnd/commits/taproot-interop/, I can open channels, pay, but not close them, though lnd sends a different error this time: unable to combine final co-op close sig: error combining partial signature: error combining partial signature: final signature is invalid. RevokeAndAck's single nonce TLV is still there and should be removed.
Re-connecting works now, provided that I implement ChannelReestablish's single nonce TLV which too should be removed.

@ZmnSCPxj
Copy link
Collaborator

ZmnSCPxj commented Dec 14, 2025

I would like to bring up the following thought: Taproot allows MuSig2 to be used, and in addition, nested.

(Let us set aside the fact that MuSig2-in-MuSig2 has not been proven safe as of now yet)

So, in theory, it would be possible with Simple Taproot Channels to have Bob be composed of two signers, Bobbie and Bobbette.

However, a difficulty arises due to the use of shachains in the revocation scheme. As far as I can tell from this current spec, we are still using shachains for the revocation scheme (and in fact it is being recommended, though not required (?), to be used to derive the JIT nonces R' for signing).

The problem is that who knows the revocation key seed? Ideally, given signers Bobbie and Bobbette, neither should be able to know the entire revocation key seed. The problem is that we use shachains for the revocation key in order to reduce storage for the channel state.

A shachain is constructed by repeatedly hashing the revocation key seed. This requires that some single entity have the revocation key seed in order to be able to give a specific revocation key to the counterparty. This loses some of the advantages of having multisignature signers. If both Bobbie and Bobbette know the revocation key seed, Alice need only hack one of them, extract the revocation key seed, then send an error message to the Bob node, which according to specifications, should cause Bob to drop the channel onchain using the Bob unilateral transaction. But with Alice having extricated the revocation key seed, it can revoke the latest state and outright spend the entire channel amount.

On the other hand, we should note the following facts:

  • Post-anchor-commitments, the only new state is for HTLCs.
    • Each HTLC represents two state changes: one to add it, and one to fail or fulfill it (remove it).
  • HTLCs are not compressible in the same way that shachain-based revocation keys are.
  • Since you need linear (in the number of HTLCs ever transmitted on the channel) storage anyway, you might as well leave off shachains; you still need linear storage to properly punish anyway, as HTLCs exist.
    • Even with the use of the revocation key as internaly pubkey path, you still need to store the Merkle Tree Root of the HTLC contract anyway (because it is the tweak on the revocation key), so even if you do not store the HTLC hash and timeout, you still need to store 32 bytes per HTLC, i.e. still linear storage.

Can we consider removing the shachain requirement and just have the other side always store all revocation keys in an incompressible manner? If not now, then maybe at some point in the future?

Copy link
Collaborator

@t-bast t-bast left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks mostly good to me, I have a couple of minor nits but that's all! I wonder if we should now include test vectors (since you prepared a section for it at the bottom) if we're almost ready to merge?

Comment on lines +767 to +779
Compared to the base segwit v0 channel type, for simple taproot channels, the
co-op close transaction now always signals RBF. In other words, the sequence
field of the sole input to the cooperative close transaction MUST be
less-than-or-equal to `0xfffffffd`. This enables a future cooperative closure
flow to support increasing the fee of subsequent close offers via RBF.

In addition, rather than adopt the existing cooperative closure fee rate
"negotiation", the responder SHOULD now always accept the offer sent by the
initiator. In other words, the cooperative close process now terminates after
exactly 1 RTTs: initiator sends sigs with offer, with the responder echo'ing
back the same fee rate. This serves to ensure that the co-op close process
always terminates deterministically, and also plays nicer with the nonces: only
a single message is ever signed by both sides for a coop close workflow.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like I missed that paragraph in my commits, which refers to the legacy closing protocol. Now that we updated this spec to rely on option_simple_close whenever using official taproot channels, we should remove this paragraph?

It looks like we can actually merge this section and the "Channel Cooperative Close Extensions" below into a single section and remove references to legacy vs modern close entirely?

Comment on lines +797 to +799
will use when sending `closing_sig` messages. This applies to both the legacy
`closing_signed` flow and the modern RBF flow using
`closing_complete`/`closing_sig`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed that one as well :/

Suggested change
will use when sending `closing_sig` messages. This applies to both the legacy
`closing_signed` flow and the modern RBF flow using
`closing_complete`/`closing_sig`.
will use when sending `closing_sig` messages.

`inclusion_proof` must be specified along with the control block. This
`inclusion_proof` is simply the `tap_leaf` hash of the path _not_ taken.

TODO(roasbeef): specify full witnesses?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove, I don't think we need to include that?

@t-bast
Copy link
Collaborator

t-bast commented Dec 15, 2025

Can we consider removing the shachain requirement and just have the other side always store all revocation keys in an incompressible manner? If not now, then maybe at some point in the future?

I think it's out of scope for this feature, there's already a lot going on in this "simple" taproot channel! It would be a big change to also change the revocation mechanism, especially since using nested musig is not something anybody plans to seriously work on in the near future.

We can have a dedicated feature bit (and dedicated PR) for that whenever we have implementations willing to actually ship nested musig features.

@sstone
Copy link
Collaborator

sstone commented Dec 15, 2025

Additional info: I tested against https://github.com/Roasbeef/lnd/commits/taproot-interop/, I can open channels, pay, but not close them, though lnd sends a different error this time: unable to combine final co-op close sig: error combining partial signature: error combining partial signature: final signature is invalid. RevokeAndAck's single nonce TLV is still there and should be removed. Re-connecting works now, provided that I implement ChannelReestablish's single nonce TLV which too should be removed.

Update: I tested again against Roasbeef/lnd@7552d18, using our own branch at ACINQ/eclair@1413e8c

  • Opening and paying works, but lnd's RevokeAndAck is not using a nonce map yet.
  • reconnect works as-is now , using a nonce map TLV (so I guess it's just a matter of extending this change to RevokeAndAck)
  • but closing channels still fails with the same unable to combine final co-op close sig: error combining partial signature: error combining partial signature: final signature is invalid error sent by lnd. Closing signatures received by eclair (which initiates the closing process) are valid and eclair can publish its closing transaction.

@sstone
Copy link
Collaborator

sstone commented Jan 5, 2026

Additional info: I tested against https://github.com/Roasbeef/lnd/commits/taproot-interop/, I can open channels, pay, but not close them, though lnd sends a different error this time: unable to combine final co-op close sig: error combining partial signature: error combining partial signature: final signature is invalid. RevokeAndAck's single nonce TLV is still there and should be removed. Re-connecting works now, provided that I implement ChannelReestablish's single nonce TLV which too should be removed.

Update: I tested again against Roasbeef/lnd@7552d18, using our own branch at ACINQ/eclair@1413e8c

  • Opening and paying works, but lnd's RevokeAndAck is not using a nonce map yet.
  • reconnect works as-is now , using a nonce map TLV (so I guess it's just a matter of extending this change to RevokeAndAck)
  • but closing channels still fails with the same unable to combine final co-op close sig: error combining partial signature: error combining partial signature: final signature is invalid error sent by lnd. Closing signatures received by eclair (which initiates the closing process) are valid and eclair can publish its closing transaction.

Update: all basic interop tests pass (open/send/receive/close/re-connect) between lnd (lightningnetwork/lnd@d4097b1) and eclair (ACINQ/eclair@ea9c4ca) . The version of eclair used for testing implements the changes and messages defined in this PR at 9150ef6, without extensions/interop workarounds (they're not needed anymore).

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.