watchtower: add production taproot channel support to justice kit
What changed, and why it matters
This commit fixes a bug in LND's watchtower (a service that helps recover funds if a channel partner tries to cheat) so it correctly handles production taproot channels. Before this fix, the watchtower would use the wrong script variant when building recovery transactions, producing invalid transactions that could not actually sweep the breached funds. The change wires the channel type through several internal data structures and adds a new blob type for final/production taproot channels. A small related change also makes the RBF cooperative close protocol use the current block height instead of a fixed height at startup, and forces RBF cooperative close to be enabled whenever taproot channels are enabled.
Treat this as a functional correctness fix with security implications for watchtower-backed taproot channels. Users running watchtowers and production taproot channels should upgrade so that justice transactions will be valid. Review whether any already-created watchtower state for production taproot channels used the wrong blob type and may need re-backup or migration. No immediate active-exploitation vector is evident from the diff.
Security signals we found
Incorrect script variant used for production taproot channels could prevent watchtower from sweeping breached outputs
New blob flag and commitment type added to differentiate staging vs production taproot scripts
BreachRetribution now carries ChanType to drive script-variant selection
taprootJusticeKit now applies WithProdScripts() based on channel type
RBF cooperative close height check now uses current best block height instead of FSM creation height
RBF cooperative close automatically enabled when taproot channels are enabled
Evidence from the diff
The patch adds ChanType to BreachRetribution and propagates it through the watchtower blob/justice-kit system. It introduces FlagTaprootFinalChannel and TypeAltruistTaprootFinalCommit to distinguish production taproot channels from staging ones, adds TaprootFinalCommitment to the CommitmentType enum with correct witness types/sizes, and updates taprootJusticeKit to call input.WithProdScripts() when isFinal is true. Without this, watchtowers would construct justice transactions using staging scripts for production taproot channels, yielding invalid witnesses and failed sweeps. The commit also changes RbfMsgMapper to use a dynamic best-height callback rather than a static blockHeight, and forces RbfCoopClose=true when either taproot channel option is enabled.
Changed components
watchtower/blob/commitments.gowatchtower/blob/justice_kit.gowatchtower/blob/type.golnwallet/channel.go (BreachRetribution)lnrpc/wtclientrpc/wtclient.goserver.go (tower client manager policy setup)lnwallet/chancloser/rbf_coop_msg_mapper.gopeer/brontide.golnwallet/chancloser/rbf_coop_test.gowatchtower/blob/type_test.goInspect captured patch +150 / −51
diff --git a/lnrpc/wtclientrpc/wtclient.go b/lnrpc/wtclientrpc/wtclient.go
index 5ddb99d..551c223 100644
--- a/lnrpc/wtclientrpc/wtclient.go
+++ b/lnrpc/wtclientrpc/wtclient.go
@@ -585,7 +585,9 @@ func marshallTower(tower *wtclient.RegisteredTower, policyType PolicyType,
func blobTypeToPolicyType(t blob.Type) (PolicyType, error) {
switch t {
- case blob.TypeAltruistTaprootCommit:
+ case blob.TypeAltruistTaprootCommit,
+ blob.TypeAltruistTaprootFinalCommit:
+
return PolicyType_TAPROOT, nil
case blob.TypeAltruistAnchorCommit:
diff --git a/lnwallet/chancloser/rbf_coop_msg_mapper.go b/lnwallet/chancloser/rbf_coop_msg_mapper.go
index 1141e36..2e4079a 100644
--- a/lnwallet/chancloser/rbf_coop_msg_mapper.go
+++ b/lnwallet/chancloser/rbf_coop_msg_mapper.go
@@ -11,10 +11,11 @@ import (
// rbf-coop close state machine. This enables the state machine to be used with
// protofsm.
type RbfMsgMapper struct {
- // blockHeight is the height of the block when the co-op close request
- // was initiated. This is used to validate conditions related to the
- // thaw height.
- blockHeight uint32
+ // bestHeight returns the current best block height. This is used
+ // instead of a static height so that thaw height checks reflect the
+ // actual chain state when messages are received, not the height at
+ // FSM creation time.
+ bestHeight func() uint32
// chanID is the channel ID of the channel being closed.
chanID lnwire.ChannelID
@@ -24,15 +25,15 @@ type RbfMsgMapper struct {
peerPub btcec.PublicKey
}
-// NewRbfMsgMapper creates a new RbfMsgMapper instance given the current block
-// height when the co-op close request was initiated.
-func NewRbfMsgMapper(blockHeight uint32,
+// NewRbfMsgMapper creates a new RbfMsgMapper instance given a function that
+// returns the current best block height.
+func NewRbfMsgMapper(bestHeight func() uint32,
chanID lnwire.ChannelID, peerPub btcec.PublicKey) *RbfMsgMapper {
return &RbfMsgMapper{
- blockHeight: blockHeight,
- chanID: chanID,
- peerPub: peerPub,
+ bestHeight: bestHeight,
+ chanID: chanID,
+ peerPub: peerPub,
}
}
@@ -64,7 +65,7 @@ func (r *RbfMsgMapper) MapMsg(wireMsg msgmux.PeerMsg) fn.Option[ProtocolEvent] {
})
return someEvent(&ShutdownReceived{
- BlockHeight: r.blockHeight,
+ BlockHeight: r.bestHeight(),
ShutdownScript: msg.Address,
RemoteShutdownNonce: remoteShutdownNonce,
})
diff --git a/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go
index 5338818..e8bbcc3 100644
--- a/lnwallet/chancloser/rbf_coop_test.go
+++ b/lnwallet/chancloser/rbf_coop_test.go
@@ -957,7 +957,10 @@ func newRbfCloserTestHarness(t *testing.T,
peerPub := randPubKey(t)
- msgMapper := NewRbfMsgMapper(uint32(startingHeight), chanID, *peerPub)
+ msgMapper := NewRbfMsgMapper(
+ func() uint32 { return uint32(startingHeight) },
+ chanID, *peerPub,
+ )
initialState := cfg.initialState.UnwrapOr(&ChannelActive{})
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index 866628d..694aaed 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -2081,6 +2081,11 @@ type BreachRetribution struct {
// RemoteResolutionBlob is a blob used for aux channels that permits an
// honest party to sweep the remote commitment output.
RemoteResolutionBlob fn.Option[tlv.Blob]
+
+ // ChanType is the channel type of the breached channel, used to
+ // determine whether production taproot scripts should be used when
+ // constructing justice transactions.
+ ChanType channeldb.ChannelType
}
// NewBreachRetribution creates a new fully populated BreachRetribution for the
@@ -2626,6 +2631,7 @@ func createBreachRetribution(revokedLog *channeldb.RevocationLog,
RemoteOutpoint: theirOutpoint,
HtlcRetributions: htlcRetributions,
KeyRing: keyRing,
+ ChanType: chanState.ChanType,
}, ourAmt, theirAmt, nil
}
@@ -2701,6 +2707,7 @@ func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment,
RemoteOutpoint: theirOutpoint,
HtlcRetributions: htlcRetributions,
KeyRing: keyRing,
+ ChanType: chanState.ChanType,
}, ourAmt, theirAmt, nil
}
diff --git a/peer/brontide.go b/peer/brontide.go
index 90cb138..2ec32b0 100644
--- a/peer/brontide.go
+++ b/peer/brontide.go
@@ -4029,7 +4029,18 @@ func (p *Brontide) initRbfChanCloser(
peerPub := *p.IdentityKey()
msgMapper := chancloser.NewRbfMsgMapper(
- uint32(startingHeight), chanID, peerPub,
+ func() uint32 {
+ _, height, err := p.cfg.ChainIO.GetBestBlock()
+ if err != nil {
+ peerLog.Errorf("Unable to get best block "+
+ "height: %v", err)
+
+ return uint32(startingHeight)
+ }
+
+ return uint32(height)
+ },
+ chanID, peerPub,
)
initialState := chancloser.ChannelActive{}
diff --git a/server.go b/server.go
index 12cb3a0..cba68de 100644
--- a/server.go
+++ b/server.go
@@ -661,6 +661,15 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
"in a standalone lnd build")
}
+ // If either taproot channel type is enabled, we also need to enable
+ // the RBF cooperative close protocol, as it is required for taproot
+ // channel interoperability.
+ if cfg.ProtocolOptions.TaprootChans ||
+ cfg.ProtocolOptions.TaprootOverlayChans {
+
+ cfg.ProtocolOptions.RbfCoopClose = true
+ }
+
//nolint:ll
featureMgr, err := feature.NewManager(feature.Config{
NoTLVOnion: cfg.ProtocolOptions.LegacyOnion(),
@@ -1768,6 +1777,13 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
blob.FlagTaprootChannel,
)
+ // Copy the policy for legacy channels and set the blob flags
+ // signalling support for production taproot channels.
+ taprootFinalPolicy := policy
+ taprootFinalPolicy.TxPolicy.BlobType |= blob.Type(
+ blob.FlagTaprootChannel | blob.FlagTaprootFinalChannel,
+ )
+
s.towerClientMgr, err = wtclient.NewManager(&wtclient.Config{
FetchClosedChannel: fetchClosedChannel,
BuildBreachRetribution: buildBreachRetribution,
@@ -1798,7 +1814,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
MinBackoff: 10 * time.Second,
MaxBackoff: 5 * time.Minute,
MaxTasksInMemQueue: cfg.WtClient.MaxTasksInMemQueue,
- }, policy, anchorPolicy, taprootPolicy)
+ }, policy, anchorPolicy, taprootPolicy, taprootFinalPolicy)
if err != nil {
return nil, err
}
diff --git a/watchtower/blob/commitments.go b/watchtower/blob/commitments.go
index 994c55c..40daa08 100644
--- a/watchtower/blob/commitments.go
+++ b/watchtower/blob/commitments.go
@@ -30,8 +30,13 @@ const (
AnchorCommitment
// TaprootCommitment represents the commitment transaction of a simple
- // taproot channel.
+ // taproot channel using staging scripts.
TaprootCommitment
+
+ // TaprootFinalCommitment represents the commitment transaction of a
+ // production taproot channel using final scripts with
+ // OP_CHECKSIGVERIFY optimizations.
+ TaprootFinalCommitment
)
// ToLocalInput constructs the input that will be used to spend the to_local
@@ -66,7 +71,7 @@ func (c CommitmentType) ToRemoteInput(info *lnwallet.BreachRetribution) (
info.LocalOutputSignDesc, 0,
), nil
- case AnchorCommitment, TaprootCommitment:
+ case AnchorCommitment, TaprootCommitment, TaprootFinalCommitment:
// Anchor and Taproot channels have a CSV-encumbered to-remote
// output. We'll construct a CSV input and assign the proper CSV
// delay of 1.
@@ -89,6 +94,9 @@ func (c CommitmentType) ToLocalWitnessType() (input.WitnessType, error) {
case TaprootCommitment:
return input.TaprootCommitmentRevoke, nil
+ case TaprootFinalCommitment:
+ return input.TaprootCommitmentRevokeFinal, nil
+
default:
return nil, fmt.Errorf("unknown commitment type: %v", c)
}
@@ -109,6 +117,9 @@ func (c CommitmentType) ToRemoteWitnessType() (input.WitnessType, error) {
case TaprootCommitment:
return input.TaprootRemoteCommitSpend, nil
+ case TaprootFinalCommitment:
+ return input.TaprootRemoteCommitSpendFinal, nil
+
default:
return nil, fmt.Errorf("unknown commitment type: %v", c)
}
@@ -127,10 +138,14 @@ func (c CommitmentType) ToRemoteWitnessSize() (lntypes.WeightUnit, error) {
case AnchorCommitment:
return input.ToRemoteConfirmedWitnessSize, nil
- // Taproot channels spend a confirmed P2SH output.
+ // Staging taproot channels.
case TaprootCommitment:
return input.TaprootToRemoteWitnessSize, nil
+ // Production taproot channels use slightly smaller scripts.
+ case TaprootFinalCommitment:
+ return input.TaprootToRemoteWitnessSizeFinal, nil
+
default:
return 0, fmt.Errorf("unknown commitment type: %v", c)
}
@@ -153,6 +168,11 @@ func (c CommitmentType) ToLocalWitnessSize() (lntypes.WeightUnit, error) {
case TaprootCommitment:
return input.TaprootToLocalRevokeWitnessSize, nil
+ // Production taproot uses the same revoke witness size since the
+ // revocation script is identical between staging and production.
+ case TaprootFinalCommitment:
+ return input.TaprootToLocalRevokeWitnessSize, nil
+
default:
return 0, fmt.Errorf("unknown commitment type: %v", c)
}
@@ -187,7 +207,7 @@ func (c CommitmentType) ParseRawSig(witness wire.TxWitness) (lnwire.Sig,
// signature.
return lnwire.NewSigFromECDSARawSignature(rawSignature)
- case TaprootCommitment:
+ case TaprootCommitment, TaprootFinalCommitment:
rawSignature := witness[0]
if len(rawSignature) > 64 {
rawSignature = witness[0][:len(witness[0])-1]
@@ -220,7 +240,7 @@ func (c CommitmentType) NewJusticeKit(sweepScript []byte,
sweepScript, breachInfo, withToRemote,
), nil
- case TaprootCommitment:
+ case TaprootCommitment, TaprootFinalCommitment:
return newTaprootJusticeKit(
sweepScript, breachInfo, withToRemote,
)
@@ -245,6 +265,9 @@ func (c CommitmentType) EmptyJusticeKit() (JusticeKit, error) {
case TaprootCommitment:
return &taprootJusticeKit{}, nil
+ case TaprootFinalCommitment:
+ return &taprootJusticeKit{isFinal: true}, nil
+
default:
return nil, fmt.Errorf("unknown commitment type: %v", c)
}
diff --git a/watchtower/blob/justice_kit.go b/watchtower/blob/justice_kit.go
index 527cf08..90e6e94 100644
--- a/watchtower/blob/justice_kit.go
+++ b/watchtower/blob/justice_kit.go
@@ -295,6 +295,12 @@ func (a *anchorJusticeKit) ToRemoteOutputSpendInfo() (*txscript.PkScript,
// be used for backing up commitments of taproot channels.
type taprootJusticeKit struct {
justiceKitPacketV1
+
+ // isFinal indicates whether this is a production taproot channel
+ // using final scripts (OP_CHECKSIGVERIFY optimizations). This
+ // determines which script variant to use when reconstructing
+ // scripts for justice transactions.
+ isFinal bool
}
// A compile-time check to ensure that taprootJusticeKit implements the
@@ -310,13 +316,12 @@ func newTaprootJusticeKit(sweepScript []byte,
// TODO(roasbeef): aux leaf tower updates needed
- // TODO: Add channel type info to BreachRetribution to determine
- // whether to use production scripts for final taproot channels.
- // For now, we default to staging scripts.
+ // Use production scripts if this is a final taproot channel.
var scriptOpts []input.TaprootScriptOpt
- // if chanType.IsTaprootFinal() {
- // scriptOpts = append(scriptOpts, input.WithProdScripts())
- // }
+ isFinal := breachInfo.ChanType.IsTaprootFinal()
+ if isFinal {
+ scriptOpts = append(scriptOpts, input.WithProdScripts())
+ }
tree, err := input.NewLocalCommitScriptTree(
breachInfo.RemoteDelay, keyRing.ToLocalKey,
@@ -340,7 +345,10 @@ func newTaprootJusticeKit(sweepScript []byte,
packet.commitToRemotePubKey = toBlobPubKey(keyRing.ToRemoteKey)
}
- return &taprootJusticeKit{packet}, nil
+ return &taprootJusticeKit{
+ justiceKitPacketV1: packet,
+ isFinal: isFinal,
+ }, nil
}
// ToLocalOutputSpendInfo returns the info required to send the to-local
@@ -361,13 +369,11 @@ func (t *taprootJusticeKit) ToLocalOutputSpendInfo() (*txscript.PkScript,
return nil, nil, err
}
- // TODO: Add channel type info to determine whether to use production
- // scripts for final taproot channels. For now, we default to staging
- // scripts.
+ // Use production scripts if this is a final taproot channel.
var scriptOpts []input.TaprootScriptOpt
- // if chanType.IsTaprootFinal() {
- // scriptOpts = append(scriptOpts, input.WithProdScripts())
- // }
+ if t.isFinal {
+ scriptOpts = append(scriptOpts, input.WithProdScripts())
+ }
revokeScript, err := input.TaprootLocalCommitRevokeScript(
localDelayedPubKey, revocationPubKey, scriptOpts...,
@@ -436,13 +442,11 @@ func (t *taprootJusticeKit) ToRemoteOutputSpendInfo() (*txscript.PkScript,
return nil, nil, 0, err
}
- // TODO: Add channel type info to determine whether to use production
- // scripts for final taproot channels. For now, we default to staging
- // scripts.
+ // Use production scripts if this is a final taproot channel.
var scriptOpts []input.TaprootScriptOpt
- // if chanType.IsTaprootFinal() {
- // scriptOpts = append(scriptOpts, input.WithProdScripts())
- // }
+ if t.isFinal {
+ scriptOpts = append(scriptOpts, input.WithProdScripts())
+ }
scriptTree, err := input.NewRemoteCommitScriptTree(
toRemotePk, fn.None[txscript.TapLeaf](), scriptOpts...,
diff --git a/watchtower/blob/type.go b/watchtower/blob/type.go
index aee163e..9c0fbb1 100644
--- a/watchtower/blob/type.go
+++ b/watchtower/blob/type.go
@@ -30,6 +30,11 @@ const (
// FlagTaprootChannel signals that this blob is meant to spend a
// taproot channel and therefore must expect P2TR outputs.
FlagTaprootChannel Flag = 1 << 3
+
+ // FlagTaprootFinalChannel signals that this blob uses production
+ // taproot scripts (OP_CHECKSIGVERIFY instead of OP_CHECKSIG + OP_DROP)
+ // as opposed to the staging variant.
+ FlagTaprootFinalChannel Flag = 1 << 4
)
// Type returns a Type consisting solely of this flag enabled.
@@ -48,6 +53,8 @@ func (f Flag) String() string {
return "FlagAnchorChannel"
case FlagTaprootChannel:
return "FlagTaprootChannel"
+ case FlagTaprootFinalChannel:
+ return "FlagTaprootFinalChannel"
default:
return "FlagUnknown"
}
@@ -78,12 +85,21 @@ const (
// taproot channel commitment to a sweep address controlled by the user,
// and does not give the tower a reward.
TypeAltruistTaprootCommit = Type(FlagCommitOutputs | FlagTaprootChannel)
+
+ // TypeAltruistTaprootFinalCommit sweeps commitment outputs from a
+ // production taproot channel using final scripts with
+ // OP_CHECKSIGVERIFY optimizations.
+ TypeAltruistTaprootFinalCommit = Type(
+ FlagCommitOutputs | FlagTaprootChannel | FlagTaprootFinalChannel,
+ )
)
// TypeFromChannel returns the appropriate blob Type for the given channel
// type.
func TypeFromChannel(chanType channeldb.ChannelType) Type {
switch {
+ case chanType.IsTaprootFinal():
+ return TypeAltruistTaprootFinalCommit
case chanType.IsTaproot():
return TypeAltruistTaprootCommit
case chanType.HasAnchors():
@@ -104,6 +120,8 @@ func (t Type) Identifier() (string, error) {
return "reward", nil
case TypeAltruistTaprootCommit:
return "taproot", nil
+ case TypeAltruistTaprootFinalCommit:
+ return "taproot-final", nil
default:
return "", fmt.Errorf("unknown blob type: %v", t)
}
@@ -115,6 +133,9 @@ func (t Type) CommitmentType(chanType *channeldb.ChannelType) (CommitmentType,
error) {
switch {
+ case t.Has(FlagTaprootFinalChannel):
+ return TaprootFinalCommitment, nil
+
case t.Has(FlagTaprootChannel):
return TaprootCommitment, nil
@@ -158,12 +179,19 @@ func (t Type) IsTaprootChannel() bool {
return t.Has(FlagTaprootChannel)
}
+// IsTaprootFinalChannel returns true if the blob type is for a production
+// taproot channel using final scripts.
+func (t Type) IsTaprootFinalChannel() bool {
+ return t.Has(FlagTaprootFinalChannel)
+}
+
// knownFlags maps the supported flags to their name.
var knownFlags = map[Flag]struct{}{
- FlagReward: {},
- FlagCommitOutputs: {},
- FlagAnchorChannel: {},
- FlagTaprootChannel: {},
+ FlagReward: {},
+ FlagCommitOutputs: {},
+ FlagAnchorChannel: {},
+ FlagTaprootChannel: {},
+ FlagTaprootFinalChannel: {},
}
// String returns a human-readable description of a Type.
@@ -210,10 +238,11 @@ func (t Type) String() string {
// supportedTypes is the set of all configurations known to be supported by the
// package.
var supportedTypes = map[Type]struct{}{
- TypeAltruistCommit: {},
- TypeRewardCommit: {},
- TypeAltruistAnchorCommit: {},
- TypeAltruistTaprootCommit: {},
+ TypeAltruistCommit: {},
+ TypeRewardCommit: {},
+ TypeAltruistAnchorCommit: {},
+ TypeAltruistTaprootCommit: {},
+ TypeAltruistTaprootFinalCommit: {},
}
// IsSupportedType returns true if the given type is supported by the package.
diff --git a/watchtower/blob/type_test.go b/watchtower/blob/type_test.go
index 87d9a8a..7baa36c 100644
--- a/watchtower/blob/type_test.go
+++ b/watchtower/blob/type_test.go
@@ -6,7 +6,7 @@ import (
"github.com/lightningnetwork/lnd/watchtower/blob"
)
-var unknownFlag = blob.Flag(16)
+var unknownFlag = blob.Flag(32)
type typeStringTest struct {
name string
@@ -18,7 +18,8 @@ var typeStringTests = []typeStringTest{
{
name: "commit no-reward",
typ: blob.TypeAltruistCommit,
- expStr: "[No-FlagTaprootChannel|" +
+ expStr: "[No-FlagTaprootFinalChannel|" +
+ "No-FlagTaprootChannel|" +
"No-FlagAnchorChannel|" +
"FlagCommitOutputs|" +
"No-FlagReward]",
@@ -26,7 +27,8 @@ var typeStringTests = []typeStringTest{
{
name: "commit reward",
typ: blob.TypeRewardCommit,
- expStr: "[No-FlagTaprootChannel|" +
+ expStr: "[No-FlagTaprootFinalChannel|" +
+ "No-FlagTaprootChannel|" +
"No-FlagAnchorChannel|" +
"FlagCommitOutputs|" +
"FlagReward]",
@@ -34,7 +36,8 @@ var typeStringTests = []typeStringTest{
{
name: "unknown flag",
typ: unknownFlag.Type(),
- expStr: "0000000000010000[No-FlagTaprootChannel|" +
+ expStr: "0000000000100000[No-FlagTaprootFinalChannel|" +
+ "No-FlagTaprootChannel|" +
"No-FlagAnchorChannel|" +
"No-FlagCommitOutputs|" +
"No-FlagReward]",
Why this scored 54/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.