contractcourt: integrate production taproot support in nursery
What changed, and why it matters
This commit finishes adding support for a new kind of Bitcoin taproot channel in LND's 'UTXO nursery' — the component that looks after time-locked funds after a channel is force-closed. It makes sure the nursery picks the correct transaction-signature format (called a witness type) for production taproot channels, instead of accidentally using the older or staging taproot formats. The change is mostly about completing a feature and preventing sweep failures for the new channel type, not about fixing an obvious exploitable bug.
Treat as a feature-completion/maintenance patch. Reviewers should verify that every call site that should pass WithChanType does so, and that the new Final witness types are handled consistently across sweeping, reporting, and any downstream wallet/rescue code not shown in this diff. Run the new tests and existing contractcourt tests.
Security signals we found
Witness-type selection now distinguishes production taproot (Final), staging taproot, and legacy channels
New helper relies on channel type bit IsTaprootFinal rather than only script shape (IsPayToTaproot)
NurseryReport extended so new Final witness types are categorized correctly
Functional-option API change propagates through ChainArbitratorConfig, server.go, and test mocks
No explicit bug, CVE, or security disclosure referenced in commit message or diff
Evidence from the diff
The patch extends contractcourt.IncubateOutputs with variadic IncubateOption functional options, primarily WithChanType, so callers can pass the channel type. UtxoNursery then uses cfg.chanType.UnwrapOr(0).IsTaprootFinal() to decide whether to use new ‘Final’ taproot witness types (TaprootHtlcAcceptedSuccessSecondLevelFinal, TaprootHtlcOfferedRemoteTimeoutFinal, TaprootHtlcOfferedTimeoutSecondLevelFinal) versus staging taproot or legacy witness types. htlc_success_resolver.go and htlc_timeout_resolver.go now pass the resolver’s chanType. makeBabyOutput is updated to take an isFinalTaproot flag and select the matching witness type. NurseryReport switch statements are extended to recognize the new Final witness types. Tests are added for witness-type selection.
Changed components
contractcourt/utxonursery.gocontractcourt/htlc_success_resolver.gocontractcourt/htlc_timeout_resolver.gocontractcourt/chain_arbitrator.goserver.goInspect captured patch +251 / −16
diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go
index 287c871..eac63cb 100644
--- a/contractcourt/chain_arbitrator.go
+++ b/contractcourt/chain_arbitrator.go
@@ -124,7 +124,7 @@ type ChainArbitratorConfig struct {
IncubateOutputs func(wire.OutPoint,
fn.Option[lnwallet.OutgoingHtlcResolution],
fn.Option[lnwallet.IncomingHtlcResolution],
- uint32, fn.Option[int32]) error
+ uint32, fn.Option[int32], ...IncubateOption) error
// PreimageDB is a global store of all known pre-images. We'll use this
// to decide if we should broadcast a commitment transaction to claim
diff --git a/contractcourt/channel_arbitrator_test.go b/contractcourt/channel_arbitrator_test.go
index 8f695c5..37b9310 100644
--- a/contractcourt/channel_arbitrator_test.go
+++ b/contractcourt/channel_arbitrator_test.go
@@ -378,7 +378,8 @@ func createTestChannelArbitrator(t *testing.T, log ArbitratorLog,
IncubateOutputs: func(wire.OutPoint,
fn.Option[lnwallet.OutgoingHtlcResolution],
fn.Option[lnwallet.IncomingHtlcResolution],
- uint32, fn.Option[int32]) error {
+ uint32, fn.Option[int32],
+ ...IncubateOption) error {
incubateChan <- struct{}{}
return nil
diff --git a/contractcourt/htlc_success_resolver.go b/contractcourt/htlc_success_resolver.go
index 4059d94..f692fbd 100644
--- a/contractcourt/htlc_success_resolver.go
+++ b/contractcourt/htlc_success_resolver.go
@@ -667,6 +667,7 @@ func (h *htlcSuccessResolver) resolveLegacySuccessTx() error {
h.ChanPoint, fn.None[lnwallet.OutgoingHtlcResolution](),
fn.Some(h.htlcResolution),
h.broadcastHeight, fn.Some(int32(h.htlc.RefundTimeout)),
+ WithChanType(h.chanType),
)
if err != nil {
return err
diff --git a/contractcourt/htlc_success_resolver_test.go b/contractcourt/htlc_success_resolver_test.go
index fe6ee1a..f0f5dd8 100644
--- a/contractcourt/htlc_success_resolver_test.go
+++ b/contractcourt/htlc_success_resolver_test.go
@@ -81,7 +81,8 @@ func newHtlcResolverTestContext(t *testing.T,
IncubateOutputs: func(wire.OutPoint,
fn.Option[lnwallet.OutgoingHtlcResolution],
fn.Option[lnwallet.IncomingHtlcResolution],
- uint32, fn.Option[int32]) error {
+ uint32, fn.Option[int32],
+ ...IncubateOption) error {
return nil
},
diff --git a/contractcourt/htlc_timeout_resolver.go b/contractcourt/htlc_timeout_resolver.go
index 8451c4c..0728e10 100644
--- a/contractcourt/htlc_timeout_resolver.go
+++ b/contractcourt/htlc_timeout_resolver.go
@@ -509,6 +509,7 @@ func (h *htlcTimeoutResolver) resolveSecondLevelTxLegacy() error {
h.ChanPoint, fn.Some(h.htlcResolution),
fn.None[lnwallet.IncomingHtlcResolution](),
h.broadcastHeight, h.incomingHTLCExpiryHeight,
+ WithChanType(h.chanType),
)
if err != nil {
return err
diff --git a/contractcourt/htlc_timeout_resolver_test.go b/contractcourt/htlc_timeout_resolver_test.go
index 017d3d3..e97af28 100644
--- a/contractcourt/htlc_timeout_resolver_test.go
+++ b/contractcourt/htlc_timeout_resolver_test.go
@@ -299,7 +299,8 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
IncubateOutputs: func(wire.OutPoint,
fn.Option[lnwallet.OutgoingHtlcResolution],
fn.Option[lnwallet.IncomingHtlcResolution],
- uint32, fn.Option[int32]) error {
+ uint32, fn.Option[int32],
+ ...IncubateOption) error {
incubateChan <- struct{}{}
return nil
diff --git a/contractcourt/utxonursery.go b/contractcourt/utxonursery.go
index fa0f186..815ac30 100644
--- a/contractcourt/utxonursery.go
+++ b/contractcourt/utxonursery.go
@@ -399,6 +399,26 @@ func (u *UtxoNursery) Stop() error {
return nil
}
+// IncubateConfig holds optional configuration for IncubateOutputs.
+type IncubateConfig struct {
+ // chanType is the channel type, used to determine which witness type
+ // to select for taproot channels.
+ chanType fn.Option[channeldb.ChannelType]
+}
+
+// IncubateOption is a functional option that can be used to modify the behavior
+// of IncubateOutputs.
+type IncubateOption func(*IncubateConfig)
+
+// WithChanType returns an IncubateOption that sets the channel type for the
+// incubation request, enabling correct witness type selection for production
+// taproot channels.
+func WithChanType(ct channeldb.ChannelType) IncubateOption {
+ return func(cfg *IncubateConfig) {
+ cfg.chanType = fn.Some(ct)
+ }
+}
+
// IncubateOutputs sends a request to the UtxoNursery to incubate a set of
// outputs from an existing commitment transaction. Outputs need to incubate if
// they're CLTV absolute time locked, or if they're CSV relative time locked.
@@ -406,7 +426,17 @@ func (u *UtxoNursery) Stop() error {
func (u *UtxoNursery) IncubateOutputs(chanPoint wire.OutPoint,
outgoingHtlc fn.Option[lnwallet.OutgoingHtlcResolution],
incomingHtlc fn.Option[lnwallet.IncomingHtlcResolution],
- broadcastHeight uint32, deadlineHeight fn.Option[int32]) error {
+ broadcastHeight uint32, deadlineHeight fn.Option[int32],
+ opts ...IncubateOption) error {
+
+ cfg := IncubateConfig{}
+ for _, o := range opts {
+ o(&cfg)
+ }
+
+ // Determine if this is a production taproot channel based on the
+ // channel type passed via functional options.
+ isFinalTaproot := cfg.chanType.UnwrapOr(0).IsTaprootFinal()
// Add to wait group because nursery might shut down during execution of
// this function. Otherwise it could happen that nursery thinks it is
@@ -448,9 +478,12 @@ func (u *UtxoNursery) IncubateOutputs(chanPoint wire.OutPoint,
)
var witType input.StandardWitnessType
- if isTaproot {
+ switch {
+ case isFinalTaproot:
+ witType = input.TaprootHtlcAcceptedSuccessSecondLevelFinal //nolint:ll
+ case isTaproot:
witType = input.TaprootHtlcAcceptedSuccessSecondLevel
- } else {
+ default:
witType = input.HtlcAcceptedSuccessSecondLevel
}
@@ -475,6 +508,7 @@ func (u *UtxoNursery) IncubateOutputs(chanPoint wire.OutPoint,
if htlcRes.SignedTimeoutTx != nil {
htlcOutput := makeBabyOutput(
&chanPoint, &htlcRes, deadlineHeight,
+ isFinalTaproot,
)
if htlcOutput.Amount() > 0 {
@@ -492,12 +526,14 @@ func (u *UtxoNursery) IncubateOutputs(chanPoint wire.OutPoint,
)
var witType input.StandardWitnessType
- if isTaproot {
+ switch {
+ case isFinalTaproot:
+ witType = input.TaprootHtlcOfferedRemoteTimeoutFinal
+ case isTaproot:
witType = input.TaprootHtlcOfferedRemoteTimeout
- } else {
+ default:
witType = input.HtlcOfferedRemoteTimeout
}
-
// Otherwise, this is actually a kid output as we can sweep it
// once the commitment transaction confirms, and the absolute
// CLTV lock has expired. We set the CSV delay what the
@@ -568,6 +604,7 @@ func (u *UtxoNursery) IncubateOutputs(chanPoint wire.OutPoint,
return nil
}
+
// NurseryReport attempts to return a nursery report stored for the target
// outpoint. A nursery report details the maturity/sweeping progress for a
// contract that was previously force closed. If a report entry for the target
@@ -620,6 +657,8 @@ func (u *UtxoNursery) NurseryReport(
switch kid.WitnessType() {
//nolint:ll
+ case input.TaprootHtlcAcceptedSuccessSecondLevelFinal:
+ fallthrough
case input.TaprootHtlcAcceptedSuccessSecondLevel:
fallthrough
case input.HtlcAcceptedSuccessSecondLevel:
@@ -630,6 +669,7 @@ func (u *UtxoNursery) NurseryReport(
report.AddLimboStage1SuccessHtlc(&kid)
case input.HtlcOfferedRemoteTimeout,
+ input.TaprootHtlcOfferedRemoteTimeoutFinal, //nolint:ll
input.TaprootHtlcOfferedRemoteTimeout:
// This is an HTLC output on the
// commitment transaction of the remote
@@ -646,6 +686,7 @@ func (u *UtxoNursery) NurseryReport(
switch kid.WitnessType() {
case input.HtlcOfferedRemoteTimeout,
+ input.TaprootHtlcOfferedRemoteTimeoutFinal, //nolint:ll
input.TaprootHtlcOfferedRemoteTimeout:
// This is an HTLC output on the
// commitment transaction of the remote
@@ -659,6 +700,10 @@ func (u *UtxoNursery) NurseryReport(
fallthrough
case input.TaprootHtlcOfferedTimeoutSecondLevel:
fallthrough
+ case input.TaprootHtlcAcceptedSuccessSecondLevelFinal:
+ fallthrough
+ case input.TaprootHtlcOfferedTimeoutSecondLevelFinal:
+ fallthrough
case input.HtlcAcceptedSuccessSecondLevel:
fallthrough
case input.HtlcOfferedTimeoutSecondLevel:
@@ -673,17 +718,24 @@ func (u *UtxoNursery) NurseryReport(
// been swept back into the wallet. Each output
// will contribute towards the recovered
// balance.
+ //
+ //nolint:ll
switch kid.WitnessType() {
- //nolint:ll
+ case input.TaprootHtlcAcceptedSuccessSecondLevelFinal:
+ fallthrough
case input.TaprootHtlcAcceptedSuccessSecondLevel:
fallthrough
+ case input.TaprootHtlcOfferedTimeoutSecondLevelFinal:
+ fallthrough
case input.TaprootHtlcOfferedTimeoutSecondLevel:
fallthrough
case input.HtlcAcceptedSuccessSecondLevel:
fallthrough
case input.HtlcOfferedTimeoutSecondLevel:
fallthrough
+ case input.TaprootHtlcOfferedRemoteTimeoutFinal:
+ fallthrough
case input.TaprootHtlcOfferedRemoteTimeout:
fallthrough
case input.HtlcOfferedRemoteTimeout:
@@ -1381,7 +1433,8 @@ type babyOutput struct {
// reaches the delay and claim stage.
func makeBabyOutput(chanPoint *wire.OutPoint,
htlcResolution *lnwallet.OutgoingHtlcResolution,
- deadlineHeight fn.Option[int32]) babyOutput {
+ deadlineHeight fn.Option[int32],
+ isFinalTaproot bool) babyOutput {
htlcOutpoint := htlcResolution.ClaimOutpoint
blocksToMaturity := htlcResolution.CsvDelay
@@ -1391,12 +1444,14 @@ func makeBabyOutput(chanPoint *wire.OutPoint,
)
var witnessType input.StandardWitnessType
- if isTaproot {
+ switch {
+ case isFinalTaproot:
+ witnessType = input.TaprootHtlcOfferedTimeoutSecondLevelFinal
+ case isTaproot:
witnessType = input.TaprootHtlcOfferedTimeoutSecondLevel
- } else {
+ default:
witnessType = input.HtlcOfferedTimeoutSecondLevel
}
-
kid := makeKidOutput(
&htlcOutpoint, chanPoint, blocksToMaturity, witnessType,
&htlcResolution.SweepSignDesc, 0, deadlineHeight,
@@ -1490,7 +1545,9 @@ func makeKidOutput(outpoint, originChanPoint *wire.OutPoint,
// the remote peer.
isHtlc := (witnessType == input.HtlcAcceptedSuccessSecondLevel ||
witnessType == input.TaprootHtlcAcceptedSuccessSecondLevel ||
+ witnessType == input.TaprootHtlcAcceptedSuccessSecondLevelFinal ||
witnessType == input.TaprootHtlcOfferedRemoteTimeout ||
+ witnessType == input.TaprootHtlcOfferedRemoteTimeoutFinal ||
witnessType == input.HtlcOfferedRemoteTimeout)
// heightHint can be safely set to zero here, because after this
diff --git a/contractcourt/utxonursery_test.go b/contractcourt/utxonursery_test.go
index 5dcf8c7..7ca984d 100644
--- a/contractcourt/utxonursery_test.go
+++ b/contractcourt/utxonursery_test.go
@@ -1457,3 +1457,174 @@ func TestPatchZeroHeightHint(t *testing.T) {
})
}
}
+
+// TestMakeBabyOutputWitnessType verifies that makeBabyOutput selects the
+// correct witness type based on the channel type (non-taproot, staging taproot,
+// production taproot).
+func TestMakeBabyOutputWitnessType(t *testing.T) {
+ t.Parallel()
+
+ // A P2TR pkscript (OP_1 <32-byte-key>).
+ taprootPkScript := make([]byte, 34)
+ taprootPkScript[0] = txscript.OP_1
+ taprootPkScript[1] = 32
+
+ // A non-taproot pkscript (P2WSH).
+ legacyPkScript := make([]byte, 34)
+ legacyPkScript[0] = txscript.OP_0
+ legacyPkScript[1] = 32
+
+ chanPoint := wire.OutPoint{}
+
+ tests := []struct {
+ name string
+ pkScript []byte
+ isFinalTaproot bool
+ expectedWitType input.StandardWitnessType
+ }{
+ {
+ name: "non-taproot",
+ pkScript: legacyPkScript,
+ isFinalTaproot: false,
+ expectedWitType: input.HtlcOfferedTimeoutSecondLevel,
+ },
+ {
+ name: "staging taproot",
+ pkScript: taprootPkScript,
+ isFinalTaproot: false,
+ expectedWitType: input.TaprootHtlcOfferedTimeoutSecondLevel,
+ },
+ {
+ name: "production taproot final",
+ pkScript: taprootPkScript,
+ isFinalTaproot: true,
+ expectedWitType: input.TaprootHtlcOfferedTimeoutSecondLevelFinal, //nolint:ll
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ htlcRes := &lnwallet.OutgoingHtlcResolution{
+ Expiry: 500,
+ CsvDelay: 144,
+ SweepSignDesc: input.SignDescriptor{
+ Output: &wire.TxOut{
+ Value: 10000,
+ PkScript: tc.pkScript,
+ },
+ },
+ SignedTimeoutTx: &wire.MsgTx{
+ TxIn: []*wire.TxIn{{
+ Witness: [][]byte{{}},
+ }},
+ TxOut: []*wire.TxOut{{}},
+ },
+ }
+
+ baby := makeBabyOutput(
+ &chanPoint, htlcRes, fn.None[int32](),
+ tc.isFinalTaproot,
+ )
+
+ require.Equal(
+ t, tc.expectedWitType,
+ baby.WitnessType(),
+ "wrong witness type for %s", tc.name,
+ )
+ })
+ }
+}
+
+// TestIncubateConfigWitnessTypeSelection verifies that the IncubateConfig
+// correctly determines isFinalTaproot based on the channel type passed via
+// WithChanType, which drives witness type selection in IncubateOutputs.
+func TestIncubateConfigWitnessTypeSelection(t *testing.T) {
+ t.Parallel()
+
+ // A P2TR pkscript (OP_1 <32-byte-key>).
+ taprootPkScript := make([]byte, 34)
+ taprootPkScript[0] = txscript.OP_1
+ taprootPkScript[1] = 32
+
+ // Non-taproot pkscript.
+ legacyPkScript := make([]byte, 34)
+ legacyPkScript[0] = txscript.OP_0
+ legacyPkScript[1] = 32
+
+ tests := []struct {
+ name string
+
+ // pkScript determines if the output looks like taproot.
+ pkScript []byte
+
+ // chanType to pass via WithChanType.
+ chanType channeldb.ChannelType
+
+ // Expected witness types for incoming and outgoing-remote.
+ expectedIncoming input.StandardWitnessType
+ expectedOutgoing input.StandardWitnessType
+ }{
+ {
+ name: "non-taproot incoming+outgoing",
+ pkScript: legacyPkScript,
+ expectedIncoming: input.HtlcAcceptedSuccessSecondLevel,
+ expectedOutgoing: input.HtlcOfferedRemoteTimeout,
+ },
+ {
+ name: "staging taproot incoming+outgoing",
+ pkScript: taprootPkScript,
+ expectedIncoming: input.TaprootHtlcAcceptedSuccessSecondLevel, //nolint:ll
+ expectedOutgoing: input.TaprootHtlcOfferedRemoteTimeout,
+ },
+ {
+ name: "production taproot incoming+outgoing",
+ pkScript: taprootPkScript,
+ chanType: channeldb.SimpleTaprootFeatureBit |
+ channeldb.AnchorOutputsBit |
+ channeldb.SingleFunderTweaklessBit |
+ channeldb.TaprootFinalBit,
+ expectedIncoming: input.TaprootHtlcAcceptedSuccessSecondLevelFinal, //nolint:ll
+ expectedOutgoing: input.TaprootHtlcOfferedRemoteTimeoutFinal, //nolint:ll
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Build the IncubateConfig to check witness type
+ // selection logic.
+ cfg := IncubateConfig{}
+ if tc.chanType != 0 {
+ opt := WithChanType(tc.chanType)
+ opt(&cfg)
+ }
+
+ isFinalTaproot := cfg.chanType.UnwrapOr(0).IsTaprootFinal()
+
+ // Verify incoming HTLC witness type selection.
+ isTaproot := txscript.IsPayToTaproot(tc.pkScript)
+
+ var incomingWit input.StandardWitnessType
+ switch {
+ case isFinalTaproot:
+ incomingWit = input.TaprootHtlcAcceptedSuccessSecondLevelFinal //nolint:ll
+ case isTaproot:
+ incomingWit = input.TaprootHtlcAcceptedSuccessSecondLevel //nolint:ll
+ default:
+ incomingWit = input.HtlcAcceptedSuccessSecondLevel
+ }
+ require.Equal(t, tc.expectedIncoming, incomingWit)
+
+ // Verify outgoing remote HTLC witness type selection.
+ var outgoingWit input.StandardWitnessType
+ switch {
+ case isFinalTaproot:
+ outgoingWit = input.TaprootHtlcOfferedRemoteTimeoutFinal //nolint:ll
+ case isTaproot:
+ outgoingWit = input.TaprootHtlcOfferedRemoteTimeout
+ default:
+ outgoingWit = input.HtlcOfferedRemoteTimeout
+ }
+ require.Equal(t, tc.expectedOutgoing, outgoingWit)
+ })
+ }
+}
diff --git a/server.go b/server.go
index 2658db3..12cb3a0 100644
--- a/server.go
+++ b/server.go
@@ -1311,11 +1311,13 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
outHtlcRes fn.Option[lnwallet.OutgoingHtlcResolution],
inHtlcRes fn.Option[lnwallet.IncomingHtlcResolution],
broadcastHeight uint32,
- deadlineHeight fn.Option[int32]) error {
+ deadlineHeight fn.Option[int32],
+ opts ...contractcourt.IncubateOption) error {
return s.utxoNursery.IncubateOutputs(
chanPoint, outHtlcRes, inHtlcRes,
broadcastHeight, deadlineHeight,
+ opts...,
)
},
PreimageDB: s.witnessBeacon,
Why this scored 33/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.