contractcourt: add channel type support to HTLC resolvers
What changed, and why it matters
This commit prepares LND's on-chain contract-resolution code to distinguish between experimental 'staging' taproot channels and the new 'final' production taproot channels. It passes the channel type into HTLC resolvers and adds helper checks so the correct Bitcoin witness script is chosen when a channel is force-closed. The change itself is infrastructure: it does not fix an active bug, but it prevents a class of future failures where a production taproot channel could be resolved using the wrong script type.
Review the corresponding witness-generation functions (MakeTaprootHtlcSucceedInputFinal, etc.) and ensure the new *Final witness types are covered by unit and integration tests for production taproot channel force-closes. Verify that chanType is non-zero for all persisted channels that may be resolved after restart.
Security signals we found
Witness-type selection now branches on final vs staging taproot channel type
Channel type is persisted/restored via SupplementState to survive restarts
Incorrect witness type for a production taproot channel could make on-chain HTLC claims fail or be non-standard
Change is defensive/infrastructure rather than a direct vulnerability fix
Evidence from the diff
The commit threads channeldb.ChannelType through contractcourt HTLC resolver constructors (success, timeout, incoming contest, outgoing contest) and stores it in resolver structs. It adds isTaprootFinal() helpers and updates witness-type selection branches to prefer new *Final witness types when chanType.IsTaprootFinal() is true, falling back to existing taproot and legacy paths. SupplementState methods are updated to restore chanType from persisted channel state after restart. This is a correctness/forward-compatibility patch for production taproot channel support.
Changed components
contractcourt/channel_arbitrator.gocontractcourt/htlc_success_resolver.gocontractcourt/htlc_timeout_resolver.gocontractcourt/htlc_incoming_contest_resolver.gocontractcourt/htlc_outgoing_contest_resolver.goInspect captured patch +85 / −14
diff --git a/contractcourt/channel_arbitrator.go b/contractcourt/channel_arbitrator.go
index ae2ffc8..a6ae4e6 100644
--- a/contractcourt/channel_arbitrator.go
+++ b/contractcourt/channel_arbitrator.go
@@ -2434,6 +2434,13 @@ func (c *ChannelArbitrator) prepContractResolutions(
return htlcResolvers, nil
}
+ // Determine the channel type once before the resolution loop so we
+ // don't repeat the nil check on every iteration.
+ var chanType channeldb.ChannelType
+ if chanState != nil {
+ chanType = chanState.ChanType
+ }
+
// For each HTLC, we'll either act immediately, meaning we'll instantly
// fail the HTLC, or we'll act only once the transaction has been
// confirmed, in which case we'll need an HTLC resolver.
@@ -2459,8 +2466,10 @@ func (c *ChannelArbitrator) prepContractResolutions(
continue
}
+
+
resolver := newSuccessResolver(
- resolution, height, htlc, resolverCfg,
+ resolution, height, htlc, chanType, resolverCfg,
)
if chanState != nil {
resolver.SupplementState(chanState)
@@ -2487,8 +2496,10 @@ func (c *ChannelArbitrator) prepContractResolutions(
continue
}
+
+
resolver := newTimeoutResolver(
- resolution, height, htlc, resolverCfg,
+ resolution, height, htlc, chanType, resolverCfg,
)
if chanState != nil {
resolver.SupplementState(chanState)
@@ -2527,8 +2538,10 @@ func (c *ChannelArbitrator) prepContractResolutions(
continue
}
+
+
resolver := newIncomingContestResolver(
- resolution, height, htlc,
+ resolution, height, htlc, chanType,
resolverCfg,
)
if chanState != nil {
@@ -2559,8 +2572,9 @@ func (c *ChannelArbitrator) prepContractResolutions(
continue
}
+
resolver := newOutgoingContestResolver(
- resolution, height, htlc, resolverCfg,
+ resolution, height, htlc, chanType, resolverCfg,
)
if chanState != nil {
resolver.SupplementState(chanState)
diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go
index 95d08c4..ad4747a 100644
--- a/contractcourt/htlc_incoming_contest_resolver.go
+++ b/contractcourt/htlc_incoming_contest_resolver.go
@@ -42,10 +42,10 @@ type htlcIncomingContestResolver struct {
// newIncomingContestResolver instantiates a new incoming htlc contest resolver.
func newIncomingContestResolver(
res lnwallet.IncomingHtlcResolution, broadcastHeight uint32,
- htlc channeldb.HTLC, resCfg ResolverConfig) *htlcIncomingContestResolver {
+ htlc channeldb.HTLC, chanType channeldb.ChannelType, resCfg ResolverConfig) *htlcIncomingContestResolver {
success := newSuccessResolver(
- res, broadcastHeight, htlc, resCfg,
+ res, broadcastHeight, htlc, chanType, resCfg,
)
return &htlcIncomingContestResolver{
diff --git a/contractcourt/htlc_outgoing_contest_resolver.go b/contractcourt/htlc_outgoing_contest_resolver.go
index 9e94587..241117c 100644
--- a/contractcourt/htlc_outgoing_contest_resolver.go
+++ b/contractcourt/htlc_outgoing_contest_resolver.go
@@ -24,10 +24,10 @@ type htlcOutgoingContestResolver struct {
// resolver.
func newOutgoingContestResolver(res lnwallet.OutgoingHtlcResolution,
broadcastHeight uint32, htlc channeldb.HTLC,
- resCfg ResolverConfig) *htlcOutgoingContestResolver {
+ chanType channeldb.ChannelType, resCfg ResolverConfig) *htlcOutgoingContestResolver {
timeout := newTimeoutResolver(
- res, broadcastHeight, htlc, resCfg,
+ res, broadcastHeight, htlc, chanType, resCfg,
)
return &htlcOutgoingContestResolver{
diff --git a/contractcourt/htlc_success_resolver.go b/contractcourt/htlc_success_resolver.go
index a4d27ba..4059d94 100644
--- a/contractcourt/htlc_success_resolver.go
+++ b/contractcourt/htlc_success_resolver.go
@@ -50,6 +50,9 @@ type htlcSuccessResolver struct {
// htlc contains information on the htlc that we are resolving on-chain.
htlc channeldb.HTLC
+ // chanType denotes the type of channel the HTLC belongs to.
+ chanType channeldb.ChannelType
+
// currentReport stores the current state of the resolver for reporting
// over the rpc interface. This should only be reported in case we have
// a non-nil SignDetails on the htlcResolution, otherwise the nursery
@@ -67,13 +70,14 @@ type htlcSuccessResolver struct {
// newSuccessResolver instanties a new htlc success resolver.
func newSuccessResolver(res lnwallet.IncomingHtlcResolution,
broadcastHeight uint32, htlc channeldb.HTLC,
- resCfg ResolverConfig) *htlcSuccessResolver {
+ chanType channeldb.ChannelType, resCfg ResolverConfig) *htlcSuccessResolver {
h := &htlcSuccessResolver{
contractResolverKit: *newContractResolverKit(resCfg),
htlcResolution: res,
broadcastHeight: broadcastHeight,
htlc: htlc,
+ chanType: chanType,
}
h.initReport()
@@ -373,6 +377,17 @@ func (h *htlcSuccessResolver) HtlcPoint() wire.OutPoint {
return h.htlcResolution.HtlcPoint()
}
+// SupplementState allows the user of a ContractResolver to supplement it with
+// state required for the proper resolution of a contract. This restores the
+// channel type which is needed to select the correct witness type for
+// production taproot channels after restart.
+//
+// NOTE: Part of the ContractResolver interface.
+func (h *htlcSuccessResolver) SupplementState(state *channeldb.OpenChannel) {
+ h.htlcLeaseResolver.SupplementState(state)
+ h.chanType = state.ChanType
+}
+
// SupplementDeadline does nothing for an incoming htlc resolver.
//
// NOTE: Part of the htlcContractResolver interface.
@@ -409,6 +424,11 @@ func (h *htlcSuccessResolver) isTaproot() bool {
)
}
+// isTaprootFinal returns true if the htlc output is from a final taproot channel.
+func (h *htlcSuccessResolver) isTaprootFinal() bool {
+ return h.chanType.IsTaprootFinal()
+}
+
// sweepRemoteCommitOutput creates a sweep request to sweep the HTLC output on
// the remote commitment via the direct preimage-spend.
func (h *htlcSuccessResolver) sweepRemoteCommitOutput() error {
@@ -417,7 +437,18 @@ func (h *htlcSuccessResolver) sweepRemoteCommitOutput() error {
// sweeping transaction, and generate a witness.
var inp input.Input
- if h.isTaproot() {
+ if h.isTaprootFinal() {
+ inp = lnutils.Ptr(input.MakeTaprootHtlcSucceedInputFinal(
+ &h.htlcResolution.ClaimOutpoint,
+ &h.htlcResolution.SweepSignDesc,
+ h.htlcResolution.Preimage[:],
+ h.broadcastHeight,
+ h.htlcResolution.CsvDelay,
+ input.WithResolutionBlob(
+ h.htlcResolution.ResolutionBlob,
+ ),
+ ))
+ } else if h.isTaproot() {
inp = lnutils.Ptr(input.MakeTaprootHtlcSucceedInput(
&h.htlcResolution.ClaimOutpoint,
&h.htlcResolution.SweepSignDesc,
@@ -562,7 +593,9 @@ func (h *htlcSuccessResolver) sweepSuccessTxOutput() error {
// Let the sweeper sweep the second-level output now that the
// CSV/CLTV locks have expired.
var witType input.StandardWitnessType
- if h.isTaproot() {
+ if h.isTaprootFinal() {
+ witType = input.TaprootHtlcAcceptedSuccessSecondLevelFinal
+ } else if h.isTaproot() {
witType = input.TaprootHtlcAcceptedSuccessSecondLevel
} else {
witType = input.HtlcAcceptedSuccessSecondLevel
diff --git a/contractcourt/htlc_timeout_resolver.go b/contractcourt/htlc_timeout_resolver.go
index 6beafc3..8451c4c 100644
--- a/contractcourt/htlc_timeout_resolver.go
+++ b/contractcourt/htlc_timeout_resolver.go
@@ -47,6 +47,9 @@ type htlcTimeoutResolver struct {
// htlc contains information on the htlc that we are resolving on-chain.
htlc channeldb.HTLC
+ // chanType denotes the type of channel the HTLC belongs to.
+ chanType channeldb.ChannelType
+
// currentReport stores the current state of the resolver for reporting
// over the rpc interface. This should only be reported in case we have
// a non-nil SignDetails on the htlcResolution, otherwise the nursery
@@ -69,13 +72,14 @@ type htlcTimeoutResolver struct {
// newTimeoutResolver instantiates a new timeout htlc resolver.
func newTimeoutResolver(res lnwallet.OutgoingHtlcResolution,
broadcastHeight uint32, htlc channeldb.HTLC,
- resCfg ResolverConfig) *htlcTimeoutResolver {
+ chanType channeldb.ChannelType, resCfg ResolverConfig) *htlcTimeoutResolver {
h := &htlcTimeoutResolver{
contractResolverKit: *newContractResolverKit(resCfg),
htlcResolution: res,
broadcastHeight: broadcastHeight,
htlc: htlc,
+ chanType: chanType,
}
h.initReport()
@@ -91,6 +95,11 @@ func (h *htlcTimeoutResolver) isTaproot() bool {
)
}
+// isTaprootFinal returns true if the htlc output is from a final taproot channel.
+func (h *htlcTimeoutResolver) isTaprootFinal() bool {
+ return h.chanType.IsTaprootFinal()
+}
+
// outpoint returns the outpoint of the HTLC output we're attempting to sweep.
func (h *htlcTimeoutResolver) outpoint() wire.OutPoint {
// The primary key for this resolver will be the outpoint of the HTLC
@@ -514,7 +523,9 @@ func (h *htlcTimeoutResolver) resolveSecondLevelTxLegacy() error {
// are resolved via this path.
func (h *htlcTimeoutResolver) sweepDirectHtlcOutput() error {
var htlcWitnessType input.StandardWitnessType
- if h.isTaproot() {
+ if h.isTaprootFinal() {
+ htlcWitnessType = input.TaprootHtlcOfferedRemoteTimeoutFinal
+ } else if h.isTaproot() {
htlcWitnessType = input.TaprootHtlcOfferedRemoteTimeout
} else {
htlcWitnessType = input.HtlcOfferedRemoteTimeout
@@ -754,6 +765,17 @@ func (h *htlcTimeoutResolver) HtlcPoint() wire.OutPoint {
return h.htlcResolution.HtlcPoint()
}
+// SupplementState allows the user of a ContractResolver to supplement it with
+// state required for the proper resolution of a contract. This restores the
+// channel type which is needed to select the correct witness type for
+// production taproot channels after restart.
+//
+// NOTE: Part of the ContractResolver interface.
+func (h *htlcTimeoutResolver) SupplementState(state *channeldb.OpenChannel) {
+ h.htlcLeaseResolver.SupplementState(state)
+ h.chanType = state.ChanType
+}
+
// SupplementDeadline sets the incomingHTLCExpiryHeight for this outgoing htlc
// resolver.
//
@@ -1029,7 +1051,9 @@ func (h *htlcTimeoutResolver) sweepTimeoutTxOutput() error {
}
var witType input.StandardWitnessType
- if h.isTaproot() {
+ if h.isTaprootFinal() {
+ witType = input.TaprootHtlcOfferedTimeoutSecondLevelFinal
+ } else if h.isTaproot() {
witType = input.TaprootHtlcOfferedTimeoutSecondLevel
} else {
witType = input.HtlcOfferedTimeoutSecondLevel
Why this scored 32/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.