contractcourt: align final htlc handling in contest resolver
What changed, and why it matters
This change fixes a mismatch between how Lightning Network payment checks are done while a payment is still flowing through the network versus when the channel is forced on-chain. Previously, the on-chain resolver could settle an exit-hop HTLC even if its amount or expiry did not match the invoice's final-hop rules, because it only checked the preimage. Now the on-chain resolver applies the same final-hop validation as the regular link path, and refuses to settle invalid final HTLCs. A special hook is preserved for custom/auxiliary HTLCs so they can still use their own amount validation.
Treat this as a security-hardening fix and include it in the next maintenance release. Review whether any production nodes have settled on-chain HTLCs that would now be classified invalid, and consider whether a CVE or security advisory is warranted if the prior behavior allowed loss of funds or invoice bypass. No immediate emergency action is indicated by the diff alone.
Security signals we found
On-chain resolver now rejects final-hop HTLCs with mismatched amount or expiry, preventing settlement of non-conforming HTLCs that previously could be settled with only a preimage
Aligns on-chain and off-chain final-hop handling, reducing a class of inconsistency bugs
Custom HTLC amount validation remains delegated to auxiliary channel logic, preserving existing extension behavior
Evidence from the diff
The commit adds final-hop HTLC validation to contractcourt’s incoming contest resolver. It mirrors the link-level checks using hop.ValidateFinalHtlc with invoices.MaxFinalCltvDelta, validating amount and CLTV delta for exit-hop HTLCs. If validation fails, the resolver marks the HTLC as abandoned/failed and skips invoice-registry notification and preimage application. A new CustomHtlcChecker interface lets auxiliary traffic shapers opt their custom HTLCs out of the standard amount check while still enforcing final CLTV correctness. The server wires the existing AuxTrafficShaper into the ChainArbitratorConfig as the checker.
Changed components
contractcourt/htlc_incoming_contest_resolver.gocontractcourt/chain_arbitrator.gocontractcourt/interfaces.goserver.gocontractcourt/htlc_incoming_contest_resolver_test.gocontractcourt/mock_registry_test.goInspect captured patch +207 / −8
diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go
index 712537f..40ef96c 100644
--- a/contractcourt/chain_arbitrator.go
+++ b/contractcourt/chain_arbitrator.go
@@ -75,6 +75,11 @@ type ChainArbitratorConfig struct {
// htlcs. This value can be lower than the incoming broadcast delta.
OutgoingBroadcastDelta uint32
+ // CustomHtlcChecker optionally identifies HTLCs that should bypass the
+ // standard final-hop amount check because their amount validation is
+ // handled by auxiliary channel logic.
+ CustomHtlcChecker fn.Option[CustomHtlcChecker]
+
// NewSweepAddr is a function that returns a new address under control
// by the wallet. We'll use this to sweep any no-delay outputs as a
// result of unilateral channel closes.
diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go
index c3e511c..e5047c7 100644
--- a/contractcourt/htlc_incoming_contest_resolver.go
+++ b/contractcourt/htlc_incoming_contest_resolver.go
@@ -79,6 +79,31 @@ func (h *htlcIncomingContestResolver) processFinalHtlcFail() error {
return nil
}
+// invalidFinalHtlc returns true if the HTLC is an exit-hop HTLC that fails
+// final-hop validation.
+func (h *htlcIncomingContestResolver) invalidFinalHtlc(
+ payload *hop.Payload, height uint32) bool {
+
+ if payload.FwdInfo.NextHop != hop.Exit {
+ return false
+ }
+
+ // Custom HTLCs still enforce final CLTV correctness, but leave amount
+ // validation to auxiliary channel logic.
+ validateAmount := !fn.MapOptionZ(
+ h.CustomHtlcChecker,
+ func(checker CustomHtlcChecker) bool {
+ return checker.IsCustomHTLC(h.htlc.CustomRecords)
+ },
+ )
+
+ return hop.ValidateFinalHtlc(
+ h.htlc.Amt, h.htlcExpiry, height,
+ invoices.MaxFinalCltvDelta, payload.FwdInfo,
+ validateAmount,
+ ) != hop.FinalHtlcValid
+}
+
// Launch will call the inner resolver's launch method if the preimage can be
// found, otherwise it's a no-op.
func (h *htlcIncomingContestResolver) Launch() error {
@@ -102,7 +127,7 @@ func (h *htlcIncomingContestResolver) Launch() error {
return nil
}
- h.log.Debugf("found preimage for htlc=%x, transforming into success "+
+ h.log.Debugf("found preimage for htlc=%x, transforming into success "+
"resolver and launching it", h.htlc.RHash)
// Once we've applied the preimage, we'll launch the inner resolver to
@@ -178,6 +203,32 @@ func (h *htlcIncomingContestResolver) Resolve() (ContractResolver, error) {
log.Debugf("%T(%v): Resolving incoming HTLC(expiry=%v, height=%v)", h,
h.htlcResolution.ClaimOutpoint, h.htlcExpiry, currentHeight)
+ // If this final-hop HTLC does not match the expected final-hop details,
+ // keep the on-chain path aligned with link-level handling by recording
+ // a failed final outcome and leaving timeout resolution to the remote
+ // party.
+ if h.invalidFinalHtlc(payload, uint32(currentHeight)) {
+ log.Infof("%T(%v): final-hop HTLC did not match expected "+
+ "details (amt=%v, expected_amt=%v, expiry=%v, "+
+ "expected_expiry=%v, height=%v, max=%v), resolving as "+
+ "failed", h, h.htlcResolution.ClaimOutpoint,
+ h.htlc.Amt, payload.FwdInfo.AmountToForward,
+ h.htlcExpiry, payload.FwdInfo.OutgoingCTLV,
+ currentHeight, invoices.MaxFinalCltvDelta)
+ h.markResolved()
+
+ if err := h.processFinalHtlcFail(); err != nil {
+ return nil, err
+ }
+
+ report := h.report().resolverReport(
+ nil, channeldb.ResolverTypeIncomingHtlc,
+ channeldb.ResolverOutcomeAbandoned,
+ )
+
+ return nil, h.Checkpoint(h, report)
+ }
+
// We'll first check if this HTLC has been timed out, if so, we can
// return now and mark ourselves as resolved. If we're past the point of
// expiry of the HTLC, then at this point the sender can sweep it, so
@@ -616,11 +667,21 @@ var _ htlcContractResolver = (*htlcIncomingContestResolver)(nil)
// NOTE: Since we have two places to query the preimage, we need to check both
// the preimage db and the invoice db to look up the preimage.
func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) {
+ // Decode the hop payload up front; both the known-preimage path and the
+ // registry lookup below rely on the decoded final-hop details.
+ payload, _, err := h.decodePayload()
+
// Query to see if we already know the preimage.
preimage, ok := h.PreimageDB.LookupPreimage(h.htlc.RHash)
// If the preimage is known, we'll apply it.
if ok {
+ if err == nil &&
+ h.invalidFinalHtlc(payload, h.broadcastHeight) {
+
+ return false, nil
+ }
+
if err := h.applyPreimage(preimage); err != nil {
return false, err
}
@@ -629,8 +690,7 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) {
return true, nil
}
- // First try to parse the payload.
- payload, _, err := h.decodePayload()
+ // Without a preimage we need a valid payload to look up the invoice.
if err != nil {
h.log.Errorf("Cannot decode payload of htlc %v", h.HtlcPoint())
@@ -640,11 +700,17 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) {
}
// Exit early if this is not the exit hop, which means we are not the
- // payment receiver and don't have preimage.
+ // payment receiver and don't have the preimage.
if payload.FwdInfo.NextHop != hop.Exit {
return false, nil
}
+ // If this final-hop HTLC does not match the expected final-hop details,
+ // let Resolve record the failed final outcome.
+ if h.invalidFinalHtlc(payload, h.broadcastHeight) {
+ return false, nil
+ }
+
// Notify registry that we are potentially resolving as an exit hop
// on-chain. If this HTLC indeed pays to an existing invoice, the
// invoice registry will tell us what to do with the HTLC. This is
diff --git a/contractcourt/htlc_incoming_contest_resolver_test.go b/contractcourt/htlc_incoming_contest_resolver_test.go
index 457f8a8..83a9780 100644
--- a/contractcourt/htlc_incoming_contest_resolver_test.go
+++ b/contractcourt/htlc_incoming_contest_resolver_test.go
@@ -9,6 +9,7 @@ import (
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/htlcswitch/hop"
"github.com/lightningnetwork/lnd/input"
@@ -260,8 +261,95 @@ func TestHtlcIncomingResolverExitCancelHodl(t *testing.T) {
ctx.waitForResult(false)
}
+// TestHtlcIncomingResolverInvalidFinalHtlc asserts that an exit-hop HTLC with
+// final-hop details outside the expected range resolves without querying the
+// invoice registry for a preimage.
+func TestHtlcIncomingResolverInvalidFinalHtlc(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ cachePreimage bool
+ mutate func(*incomingResolverTestContext)
+ }{{
+ name: "expiry too far",
+ mutate: func(ctx *incomingResolverTestContext) {
+ ctx.resolver.htlcExpiry = testInitialBlockHeight +
+ invoices.MaxFinalCltvDelta + 1
+ },
+ }, {
+ name: "cached preimage expiry too far",
+ cachePreimage: true,
+ mutate: func(ctx *incomingResolverTestContext) {
+ ctx.resolver.htlcExpiry = testInitialBlockHeight +
+ invoices.MaxFinalCltvDelta + 1
+ },
+ }, {
+ name: "amount too low",
+ mutate: func(ctx *incomingResolverTestContext) {
+ ctx.onionProcessor.forwardAmount = testHtlcAmount + 1
+ },
+ }, {
+ name: "final cltv too low",
+ mutate: func(ctx *incomingResolverTestContext) {
+ ctx.onionProcessor.outgoingCltv = testHtlcExpiry + 1
+ },
+ }}
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ t.Parallel()
+ defer timeout()()
+
+ ctx := newIncomingResolverTestContext(t, true)
+ if testCase.cachePreimage {
+ ctx.witnessBeacon.lookupPreimage[testResHash] =
+ testResPreimage
+ }
+
+ testCase.mutate(ctx)
+ resolution := invoices.NewSettleResolution(
+ testResPreimage, testResCircuitKey,
+ testAcceptHeight, invoices.ResultSettled,
+ )
+ ctx.registry.notifyResolution = resolution
+
+ ctx.resolve()
+ ctx.waitForResult(false)
+
+ require.EqualValues(
+ t, 0, ctx.registry.notifyCalls.Load(),
+ )
+ })
+ }
+}
+
+// TestHtlcIncomingResolverCustomHtlc asserts that a custom HTLC bypasses the
+// standard final-hop amount check in contract court, matching the link flow.
+func TestHtlcIncomingResolverCustomHtlc(t *testing.T) {
+ t.Parallel()
+ defer timeout()()
+
+ ctx := newIncomingResolverTestContext(t, true)
+ ctx.resolver.CustomHtlcChecker = fn.Some[CustomHtlcChecker](
+ mockCustomHtlcChecker{},
+ )
+ ctx.onionProcessor.forwardAmount = testHtlcAmount + 1
+ ctx.registry.notifyResolution = invoices.NewSettleResolution(
+ testResPreimage, testResCircuitKey, testAcceptHeight,
+ invoices.ResultSettled,
+ )
+
+ ctx.resolve()
+ ctx.waitForResult(true)
+
+ require.NotZero(t, ctx.registry.notifyCalls.Load())
+}
+
type mockHopIterator struct {
- isExit bool
+ isExit bool
+ forwardAmount int
+ outgoingCltv uint32
hop.Iterator
}
@@ -271,11 +359,21 @@ func (h *mockHopIterator) HopPayload() (*hop.Payload, hop.RouteRole, error) {
nextAddress = [8]byte{0x01}
}
+ forwardAmount := h.forwardAmount
+ if forwardAmount == 0 {
+ forwardAmount = 100
+ }
+
+ outgoingCltv := h.outgoingCltv
+ if outgoingCltv == 0 {
+ outgoingCltv = 40
+ }
+
return hop.NewLegacyPayload(&sphinx.HopData{
Realm: [1]byte{},
NextAddress: nextAddress,
- ForwardAmount: 100,
- OutgoingCltv: 40,
+ ForwardAmount: uint64(forwardAmount),
+ OutgoingCltv: outgoingCltv,
ExtraBytes: [12]byte{},
}), hop.RouteRoleCleartext, nil
}
@@ -286,6 +384,8 @@ func (h *mockHopIterator) EncodeNextHop(w io.Writer) error {
type mockOnionProcessor struct {
isExit bool
+ forwardAmount int
+ outgoingCltv uint32
offeredOnionBlob []byte
}
@@ -298,7 +398,17 @@ func (o *mockOnionProcessor) ReconstructHopIterator(r io.Reader, rHash []byte,
}
o.offeredOnionBlob = data
- return &mockHopIterator{isExit: o.isExit}, nil
+ return &mockHopIterator{
+ isExit: o.isExit,
+ forwardAmount: o.forwardAmount,
+ outgoingCltv: o.outgoingCltv,
+ }, nil
+}
+
+type mockCustomHtlcChecker struct{}
+
+func (m mockCustomHtlcChecker) IsCustomHTLC(lnwire.CustomRecords) bool {
+ return true
}
type incomingResolverTestContext struct {
diff --git a/contractcourt/interfaces.go b/contractcourt/interfaces.go
index 7253cfa..f89de2e 100644
--- a/contractcourt/interfaces.go
+++ b/contractcourt/interfaces.go
@@ -37,6 +37,15 @@ type Registry interface {
HodlUnsubscribeAll(subscriber chan<- interface{})
}
+// CustomHtlcChecker identifies HTLCs whose final-hop amount validation is
+// handled by auxiliary channel logic instead of the standard onion amount
+// field.
+type CustomHtlcChecker interface {
+ // IsCustomHTLC returns true if the HTLC carries custom records that
+ // make it subject to auxiliary HTLC handling.
+ IsCustomHTLC(htlcRecords lnwire.CustomRecords) bool
+}
+
// OnionProcessor is an interface used to decode onion blobs.
type OnionProcessor interface {
// ReconstructHopIterator attempts to decode a valid sphinx packet from
diff --git a/contractcourt/mock_registry_test.go b/contractcourt/mock_registry_test.go
index 0530ab5..9dd0dea 100644
--- a/contractcourt/mock_registry_test.go
+++ b/contractcourt/mock_registry_test.go
@@ -2,6 +2,7 @@ package contractcourt
import (
"context"
+ "sync/atomic"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/invoices"
@@ -21,6 +22,7 @@ type mockRegistry struct {
notifyChan chan notifyExitHopData
notifyErr error
notifyResolution invoices.HtlcResolution
+ notifyCalls atomic.Int32
}
func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash,
@@ -29,6 +31,8 @@ func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash,
wireCustomRecords lnwire.CustomRecords,
payload invoices.Payload) (invoices.HtlcResolution, error) {
+ r.notifyCalls.Add(1)
+
// Exit early if the notification channel is nil.
if hodlChan == nil {
return r.notifyResolution, r.notifyErr
diff --git a/server.go b/server.go
index 078f929..6baeaea 100644
--- a/server.go
+++ b/server.go
@@ -1363,6 +1363,11 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
ChainHash: *s.cfg.ActiveNetParams.GenesisHash,
IncomingBroadcastDelta: lncfg.DefaultIncomingBroadcastDelta,
OutgoingBroadcastDelta: lncfg.DefaultOutgoingBroadcastDelta,
+ CustomHtlcChecker: fn.MapOption(
+ func(t htlcswitch.AuxTrafficShaper) contractcourt.CustomHtlcChecker {
+ return t
+ },
+ )(s.implCfg.TrafficShaper),
NewSweepAddr: func() ([]byte, error) {
addr, err := newSweepPkScriptGen(
cc.Wallet, netParams,
Why this scored 59/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.