htlcswitch+invoices: align final-hop CLTV handling
What changed, and why it matters
This commit tightens the checks that Lightning nodes perform when they are the final recipient of a payment. It makes sure the payment's time-lock expiry is not unreasonably far in the future, matching the same limit already used when forwarding payments. It also moves the final-hop amount and time-lock checks into a shared helper so invoice creation and HTLC handling stay consistent. The change is defensive and reduces the risk of abuse involving very long-dated payment locks, but the commit message does not frame it as a security fix and no CVE or advisory is referenced.
Review the new MaxFinalCltvDelta and MaxOutgoingCltvExpiry defaults to ensure they match operational expectations, and verify that the new failure responses (FailIncorrectDetails for final-hop expiry-too-far, FailExpiryTooFar for forwarding) propagate correctly without breaking legitimate payments that use large but previously accepted CLTV deltas.
Security signals we found
Adds explicit upper-bound validation for final-hop CLTV expiry
Adds explicit upper-bound validation for forwarding CLTV delta
Fixes underflow-prone subtraction in CheckHtlcForward by checking incomingTimeout < outgoingTimeout before computing delta
Centralizes final-hop validation logic to prevent divergence between invoice creation and HTLC acceptance
Adds tests for boundary conditions (exactly at maximum, one past maximum, below outgoing CLTV)
No vendor security framing, CVE, or researcher attribution present in commit or references
Evidence from the diff
The patch introduces hop.ValidateFinalHtlc, a shared helper that validates final-hop HTLC amount and CLTV expiry against the onion payload and a new invoices.MaxFinalCltvDelta bound (MaxUint16). It applies this helper in htlcswitch/link.go’s processExitHop, adding a new rejection path for final-hop CLTV deltas that exceed MaxFinalCltvDelta. It also hardens CheckHtlcForward by rejecting forwarding HTLCs whose incoming-to-outgoing CLTV delta exceeds l.cfg.MaxOutgoingCltvExpiry and by correctly handling the case where incomingTimeout < outgoingTimeout. Invoice creation in lnrpc/invoicesrpc/addinvoice.go is updated to use the same MaxFinalCltvDelta constant. Extensive unit and link-level tests are added to cover boundary conditions.
Changed components
htlcswitch/link.gohtlcswitch/hop/forwarding_info.goinvoices/invoices.golnrpc/invoicesrpc/addinvoice.goInspect captured patch +444 / −20
diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go
index 7f728f0..2bb380d 100644
--- a/htlcswitch/hop/forwarding_info.go
+++ b/htlcswitch/hop/forwarding_info.go
@@ -34,3 +34,52 @@ type ForwardingInfo struct {
// correct context.
PathID *chainhash.Hash
}
+
+// FinalHtlcValidationResult describes the result of checking a final-hop
+// HTLC against the onion payload and supported final-hop CLTV range.
+type FinalHtlcValidationResult uint8
+
+const (
+ // FinalHtlcValid indicates that the HTLC matches the final-hop payload
+ // and supported final-hop CLTV range.
+ FinalHtlcValid FinalHtlcValidationResult = iota
+
+ // FinalHtlcInvalidAmount indicates that the HTLC amount is below the
+ // final amount requested by the onion payload.
+ FinalHtlcInvalidAmount
+
+ // FinalHtlcInvalidCltv indicates that the HTLC expiry is below the
+ // final CLTV requested by the onion payload.
+ FinalHtlcInvalidCltv
+
+ // FinalHtlcExpiryTooFar indicates that the HTLC expiry is outside the
+ // supported final-hop CLTV range.
+ FinalHtlcExpiryTooFar
+)
+
+// ValidateFinalHtlc checks final-hop HTLC amount and CLTV details before
+// invoice resolution.
+func ValidateFinalHtlc(amt lnwire.MilliSatoshi, expiry, heightNow,
+ maxFinalCltvDelta uint32, fwdInfo ForwardingInfo,
+ validateAmount bool) FinalHtlcValidationResult {
+
+ switch {
+ // The HTLC amount is below the final amount requested by the
+ // onion payload.
+ case validateAmount && amt < fwdInfo.AmountToForward:
+ return FinalHtlcInvalidAmount
+
+ // The HTLC expiry is below the final CLTV requested by the onion
+ // payload.
+ case expiry < fwdInfo.OutgoingCTLV:
+ return FinalHtlcInvalidCltv
+
+ // The HTLC expiry is outside the supported final-hop CLTV range.
+ case expiry > heightNow && expiry-heightNow > maxFinalCltvDelta:
+
+ return FinalHtlcExpiryTooFar
+
+ default:
+ return FinalHtlcValid
+ }
+}
diff --git a/htlcswitch/hop/forwarding_info_test.go b/htlcswitch/hop/forwarding_info_test.go
new file mode 100644
index 0000000..68ac6f2
--- /dev/null
+++ b/htlcswitch/hop/forwarding_info_test.go
@@ -0,0 +1,137 @@
+package hop
+
+import (
+ "testing"
+
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
+)
+
+// TestValidateFinalHtlc exercises the final-hop HTLC validation helper.
+func TestValidateFinalHtlc(t *testing.T) {
+ t.Parallel()
+
+ const (
+ amount = lnwire.MilliSatoshi(1000)
+ expiry = uint32(150)
+ height = uint32(100)
+ maxCltvDelta = uint32(50)
+ )
+
+ fwdInfo := ForwardingInfo{
+ AmountToForward: amount,
+ OutgoingCTLV: expiry,
+ NextHop: Exit,
+ }
+
+ testCases := []struct {
+ name string
+ amount lnwire.MilliSatoshi
+ expiry uint32
+ height uint32
+ maxCltvDelta uint32
+ fwdInfo ForwardingInfo
+ validateAmount bool
+ expected FinalHtlcValidationResult
+ }{{
+ name: "valid",
+ amount: amount,
+ expiry: expiry,
+ height: height + 1,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: fwdInfo,
+ validateAmount: true,
+ expected: FinalHtlcValid,
+ }, {
+ name: "amount too low",
+ amount: amount - 1,
+ expiry: expiry,
+ height: height,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: fwdInfo,
+ validateAmount: true,
+ expected: FinalHtlcInvalidAmount,
+ }, {
+ name: "amount check disabled",
+ amount: amount - 1,
+ expiry: expiry,
+ height: height,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: fwdInfo,
+ validateAmount: false,
+ expected: FinalHtlcValid,
+ }, {
+ name: "final cltv too low",
+ amount: amount,
+ expiry: expiry - 1,
+ height: height,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: fwdInfo,
+ validateAmount: true,
+ expected: FinalHtlcInvalidCltv,
+ }, {
+ name: "expiry too far",
+ amount: amount,
+ expiry: expiry + 1,
+ height: height,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: fwdInfo,
+ validateAmount: true,
+ expected: FinalHtlcExpiryTooFar,
+ }, {
+ name: "expiry at maximum",
+ amount: amount,
+ expiry: expiry,
+ height: height,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: fwdInfo,
+ validateAmount: true,
+ expected: FinalHtlcValid,
+ }, {
+ name: "height above expiry",
+ amount: amount,
+ expiry: expiry,
+ height: expiry + 1,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: fwdInfo,
+ validateAmount: true,
+ expected: FinalHtlcValid,
+ }, {
+ name: "amount failure takes precedence",
+ amount: amount - 1,
+ expiry: expiry - 1,
+ height: height,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: fwdInfo,
+ validateAmount: true,
+ expected: FinalHtlcInvalidAmount,
+ }, {
+ name: "cltv failure takes precedence over " +
+ "expiry too far",
+ amount: amount,
+ expiry: expiry + maxCltvDelta + 1,
+ height: height,
+ maxCltvDelta: maxCltvDelta,
+ fwdInfo: ForwardingInfo{
+ AmountToForward: amount,
+ OutgoingCTLV: expiry + maxCltvDelta + 2,
+ NextHop: Exit,
+ },
+ validateAmount: true,
+ expected: FinalHtlcInvalidCltv,
+ }}
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := ValidateFinalHtlc(
+ testCase.amount, testCase.expiry,
+ testCase.height, testCase.maxCltvDelta,
+ testCase.fwdInfo, testCase.validateAmount,
+ )
+
+ require.Equal(t, testCase.expected, result)
+ })
+ }
+}
diff --git a/htlcswitch/link.go b/htlcswitch/link.go
index 77b2b39..bc665c9 100644
--- a/htlcswitch/link.go
+++ b/htlcswitch/link.go
@@ -2549,13 +2549,17 @@ func (l *channelLink) CheckHtlcForward(payHash [32]byte, incomingHtlcAmt,
// Finally, we'll ensure that the time-lock on the outgoing HTLC meets
// the following constraint: the incoming time-lock minus our time-lock
- // delta should equal the outgoing time lock. Otherwise, whether the
+ // delta should equal the outgoing time lock. Otherwise, either the
// sender messed up, or an intermediate node tampered with the HTLC.
timeDelta := policy.TimeLockDelta
- if incomingTimeout < outgoingTimeout+timeDelta {
+ var incomingDelta uint32
+ if incomingTimeout >= outgoingTimeout {
+ incomingDelta = incomingTimeout - outgoingTimeout
+ }
+ if incomingTimeout < outgoingTimeout || incomingDelta < timeDelta {
l.log.Warnf("incoming htlc(%x) has incorrect time-lock value: "+
"expected at least %v block delta, got %v block delta",
- payHash[:], timeDelta, incomingTimeout-outgoingTimeout)
+ payHash[:], timeDelta, incomingDelta)
// Grab the latest routing policy so the sending node is up to
// date with our current policy.
@@ -2568,6 +2572,17 @@ func (l *channelLink) CheckHtlcForward(payHash [32]byte, incomingHtlcAmt,
return NewLinkError(failure)
}
+ // Check that the incoming to outgoing time-lock delta is within the
+ // configured CLTV range.
+ if incomingDelta > l.cfg.MaxOutgoingCltvExpiry {
+ l.log.Warnf("incoming htlc(%x) has a time-lock delta "+
+ "outside the configured CLTV range: got %v, "+
+ "but maximum is %v",
+ payHash[:], incomingDelta, l.cfg.MaxOutgoingCltvExpiry)
+
+ return NewLinkError(&lnwire.FailExpiryTooFar{})
+ }
+
return nil
}
@@ -3163,7 +3178,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
obfuscator, false,
)
- l.log.Error("rejected htlc that uses use as an " +
+ l.log.Error("rejected htlc that uses us as an " +
"introduction point when we do not support " +
"route blinding")
@@ -3436,11 +3451,16 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC,
},
)
+ switch hop.ValidateFinalHtlc(
+ add.Amount, add.Expiry, heightNow,
+ invoices.MaxFinalCltvDelta,
+ fwdInfo, !isCustomHTLC,
+ ) {
// As we're the exit hop, we'll double check the hop-payload included in
// the HTLC to ensure that it was crafted correctly by the sender and
// is compatible with the HTLC we were extended. If an external
// validator is active we might bypass the amount check.
- if !isCustomHTLC && add.Amount < fwdInfo.AmountToForward {
+ case hop.FinalHtlcInvalidAmount:
l.log.Errorf("onion payload of incoming htlc(%x) has "+
"incompatible value: expected <=%v, got %v",
add.PaymentHash, add.Amount, fwdInfo.AmountToForward)
@@ -3451,11 +3471,10 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC,
l.sendHTLCError(add, sourceRef, failure, obfuscator, true)
return nil
- }
// We'll also ensure that our time-lock value has been computed
// correctly.
- if add.Expiry < fwdInfo.OutgoingCTLV {
+ case hop.FinalHtlcInvalidCltv:
l.log.Errorf("onion payload of incoming htlc(%x) has "+
"incompatible time-lock: expected <=%v, got %v",
add.PaymentHash, add.Expiry, fwdInfo.OutgoingCTLV)
@@ -3466,6 +3485,22 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC,
l.sendHTLCError(add, sourceRef, failure, obfuscator, true)
+ return nil
+
+ // Check that the incoming HTLC expiry is within the supported final-hop
+ // CLTV range.
+ case hop.FinalHtlcExpiryTooFar:
+ l.log.Warnf("incoming htlc(%x) has a final-hop CLTV delta "+
+ "outside the supported range: got %v, but maximum "+
+ "is %v",
+ add.PaymentHash, add.Expiry-heightNow,
+ invoices.MaxFinalCltvDelta)
+
+ failure := NewLinkError(
+ lnwire.NewFailIncorrectDetails(add.Amount, heightNow),
+ )
+ l.sendHTLCError(add, sourceRef, failure, obfuscator, true)
+
return nil
}
@@ -3577,8 +3612,8 @@ func (l *channelLink) forwardBatch(replay bool, packets ...*htlcPacket) {
}
}
-// sendHTLCError functions cancels HTLC and send cancel message back to the
-// peer from which HTLC was received.
+// sendHTLCError cancels the HTLC and sends a cancel message back to the peer
+// from which the HTLC was received.
func (l *channelLink) sendHTLCError(add lnwire.UpdateAddHTLC,
sourceRef channeldb.AddRef, failure *LinkError,
e hop.ErrorEncrypter, isReceive bool) {
@@ -3591,7 +3626,7 @@ func (l *channelLink) sendHTLCError(add lnwire.UpdateAddHTLC,
err = l.channel.FailHTLC(add.ID, reason, &sourceRef, nil, nil)
if err != nil {
- l.log.Errorf("unable cancel htlc: %v", err)
+ l.log.Errorf("unable to cancel htlc: %v", err)
return
}
@@ -4289,7 +4324,7 @@ func (l *channelLink) processRemoteCommitSig(ctx context.Context,
// want to ensure we release that memory back to the runtime.
l.uncommittedPreimages = nil
- // We just received a new updates to our local commitment chain,
+ // We just received new updates to our local commitment chain,
// validate this new commitment, closing the link if invalid.
auxSigBlob, err := msg.CustomRecords.Serialize()
if err != nil {
@@ -4617,7 +4652,7 @@ func (l *channelLink) processLocalUpdateFulfillHTLC(ctx context.Context,
}
// An HTLC we forward to the switch has just settled somewhere upstream.
- // Therefore we settle the HTLC within the our local state machine.
+ // Therefore we settle the HTLC within our local state machine.
inKey := pkt.inKey()
err := l.channel.SettleHTLC(
htlc.PaymentPreimage, pkt.incomingHTLCID, pkt.sourceRef,
@@ -4684,7 +4719,7 @@ func (l *channelLink) processLocalUpdateFailHTLC(ctx context.Context,
}
// An HTLC cancellation has been triggered somewhere upstream, we'll
- // remove then HTLC from our local state machine.
+ // remove the HTLC from our local state machine.
inKey := pkt.inKey()
err := l.channel.FailHTLC(
pkt.incomingHTLCID, htlc.Reason, pkt.sourceRef, pkt.destRef,
diff --git a/htlcswitch/link_isolated_test.go b/htlcswitch/link_isolated_test.go
index 9e74c48..323153e 100644
--- a/htlcswitch/link_isolated_test.go
+++ b/htlcswitch/link_isolated_test.go
@@ -237,11 +237,37 @@ func (l *linkTestContext) sendSettleBobToAlice(htlcID uint64,
l.aliceLink.HandleChannelUpdate(settle)
}
-// receiveSettleAliceToBob waits for Alice to send a HTLC settle message to
-// Bob, then hands this to Bob.
+// receiveFailAliceToBob waits for Alice to fail an HTLC to Bob.
func (l *linkTestContext) receiveFailAliceToBob() {
l.t.Helper()
+ l.receiveFailAliceToBobMsg()
+}
+
+// receiveFailAliceToBobWithCode waits for Alice to fail an HTLC to Bob and
+// verifies that the failure code matches the expectation.
+func (l *linkTestContext) receiveFailAliceToBobWithCode(
+ code lnwire.FailCode) {
+
+ l.t.Helper()
+
+ failMsg := l.receiveFailAliceToBobMsg()
+ failure, err := newMockDeobfuscator().DecryptError(failMsg.Reason)
+ if err != nil {
+ l.t.Fatalf("unable to decrypt failure: %v", err)
+ }
+
+ if failure.WireMessage().Code() != code {
+ l.t.Fatalf("expected %v but got %v",
+ code, failure.WireMessage().Code())
+ }
+}
+
+// receiveFailAliceToBobMsg waits for Alice to send a fail HTLC message to Bob,
+// applies it to Bob, and returns the message.
+func (l *linkTestContext) receiveFailAliceToBobMsg() *lnwire.UpdateFailHTLC {
+ l.t.Helper()
+
var msg lnwire.Message
select {
case msg = <-l.aliceMsgs:
@@ -258,6 +284,8 @@ func (l *linkTestContext) receiveFailAliceToBob() {
if err != nil {
l.t.Fatalf("unable to apply received fail htlc: %v", err)
}
+
+ return failMsg
}
// assertNoMsgFromAlice asserts that Alice hasn't sent a message. Before
diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go
index 148254d..d065bbb 100644
--- a/htlcswitch/link_test.go
+++ b/htlcswitch/link_test.go
@@ -6321,17 +6321,51 @@ func TestCheckHtlcForward(t *testing.T) {
})
- t.Run("cltv expiry too far in the future", func(t *testing.T) {
- // Check that expiry isn't too far in the future.
+ t.Run("cltv expiry outside supported range", func(t *testing.T) {
+ // Check that expiry stays within the supported range.
result := link.CheckHtlcForward(
hash, 1500, 1000, 10200, 10100, models.InboundFee{}, 0,
lnwire.ShortChannelID{}, nil,
)
+ _, ok := result.WireMessage().(*lnwire.FailExpiryTooFar)
+ if !ok {
+ t.Fatalf("expected FailExpiryTooFar failure code")
+ }
+ })
+
+ t.Run("incoming cltv delta outside range", func(t *testing.T) {
+ result := link.CheckHtlcForward(
+ hash, 1500, 1000, 150+DefaultMaxOutgoingCltvExpiry+1,
+ 150, models.InboundFee{}, 0, lnwire.ShortChannelID{},
+ nil,
+ )
if _, ok := result.WireMessage().(*lnwire.FailExpiryTooFar); !ok {
t.Fatalf("expected FailExpiryTooFar failure code")
}
})
+ t.Run("incoming cltv delta at maximum", func(t *testing.T) {
+ result := link.CheckHtlcForward(
+ hash, 1500, 1000, 150+DefaultMaxOutgoingCltvExpiry,
+ 150, models.InboundFee{}, 0, lnwire.ShortChannelID{},
+ nil,
+ )
+ require.Nil(t, result)
+ })
+
+ t.Run("incoming cltv below outgoing cltv", func(t *testing.T) {
+ result := link.CheckHtlcForward(
+ hash, 1500, 1000, 190, 200, models.InboundFee{}, 0,
+ lnwire.ShortChannelID{}, nil,
+ )
+ _, ok := result.WireMessage().(*lnwire.FailIncorrectCltvExpiry)
+ if !ok {
+ t.Fatalf(
+ "expected FailIncorrectCltvExpiry failure code",
+ )
+ }
+ })
+
t.Run("inbound fee satisfied", func(t *testing.T) {
t.Parallel()
@@ -6663,6 +6697,121 @@ func TestChannelLinkHoldInvoiceRestart(t *testing.T) {
}
}
+// TestChannelLinkExitHopExpiryTooFar asserts that an exit hop fails an
+// incoming HTLC if its expiry is outside the supported range.
+func TestChannelLinkExitHopExpiryTooFar(t *testing.T) {
+ t.Parallel()
+
+ const chanAmt = btcutil.SatoshiPerBitcoin * 5
+ harness, err := newSingleLinkTestHarness(t, chanAmt, 0)
+ require.NoError(t, err, "unable to create link")
+
+ if err := harness.start(); err != nil {
+ t.Fatalf("unable to start test harness: %v", err)
+ }
+ t.Cleanup(harness.aliceLink.Stop)
+
+ coreLink, ok := harness.aliceLink.(*channelLink)
+ require.True(t, ok)
+
+ registry, ok := coreLink.cfg.Registry.(*mockInvoiceRegistry)
+ require.True(t, ok)
+
+ alicePeer, ok := coreLink.cfg.Peer.(*mockPeer)
+ require.True(t, ok)
+ aliceMsgs := alicePeer.sentMsgs
+
+ registry.settleChan = make(chan lntypes.Hash)
+
+ htlc, invoice := generateHtlcAndInvoice(t, 0)
+ htlc.Expiry = testStartingHeight +
+ invpkg.MaxFinalCltvDelta + 1
+
+ err = registry.AddInvoice(t.Context(), *invoice, htlc.PaymentHash)
+ require.NoError(t, err, "unable to add invoice to registry")
+
+ ctx := linkTestContext{
+ t: t,
+ aliceSwitch: harness.aliceSwitch,
+ aliceLink: harness.aliceLink,
+ aliceMsgs: aliceMsgs,
+ bobChannel: harness.bobChannel,
+ }
+
+ ctx.sendHtlcBobToAlice(htlc)
+ ctx.sendCommitSigBobToAlice(1)
+ ctx.receiveRevAndAckAliceToBob()
+ ctx.receiveCommitSigAliceToBob(1)
+ ctx.sendRevAndAckBobToAlice()
+ ctx.receiveFailAliceToBobWithCode(
+ lnwire.CodeIncorrectOrUnknownPaymentDetails,
+ )
+ ctx.receiveCommitSigAliceToBob(0)
+
+ select {
+ case <-registry.settleChan:
+ t.Fatal("exit hop notification received")
+ case <-time.After(time.Second):
+ }
+}
+
+// TestChannelLinkExitHopExpiryAtMaximum asserts that an exit hop accepts an
+// incoming HTLC if its expiry is exactly at the maximum.
+func TestChannelLinkExitHopExpiryAtMaximum(t *testing.T) {
+ t.Parallel()
+
+ const chanAmt = btcutil.SatoshiPerBitcoin * 5
+ harness, err := newSingleLinkTestHarness(t, chanAmt, 0)
+ require.NoError(t, err, "unable to create link")
+
+ if err := harness.start(); err != nil {
+ t.Fatalf("unable to start test harness: %v", err)
+ }
+ t.Cleanup(harness.aliceLink.Stop)
+
+ coreLink, ok := harness.aliceLink.(*channelLink)
+ require.True(t, ok)
+
+ registry, ok := coreLink.cfg.Registry.(*mockInvoiceRegistry)
+ require.True(t, ok)
+
+ alicePeer, ok := coreLink.cfg.Peer.(*mockPeer)
+ require.True(t, ok)
+ aliceMsgs := alicePeer.sentMsgs
+
+ registry.settleChan = make(chan lntypes.Hash)
+
+ htlc, invoice := generateHtlcAndInvoice(t, 0)
+ htlc.Expiry = testStartingHeight +
+ invpkg.MaxFinalCltvDelta
+
+ err = registry.AddInvoice(t.Context(), *invoice, htlc.PaymentHash)
+ require.NoError(t, err, "unable to add invoice to registry")
+
+ ctx := linkTestContext{
+ t: t,
+ aliceSwitch: harness.aliceSwitch,
+ aliceLink: harness.aliceLink,
+ aliceMsgs: aliceMsgs,
+ bobChannel: harness.bobChannel,
+ }
+
+ ctx.sendHtlcBobToAlice(htlc)
+ ctx.sendCommitSigBobToAlice(1)
+ ctx.receiveRevAndAckAliceToBob()
+ ctx.receiveCommitSigAliceToBob(1)
+ ctx.sendRevAndAckBobToAlice()
+
+ select {
+ case <-registry.settleChan:
+ case <-time.After(5 * time.Second):
+ t.Fatal("expected exit hop notification")
+ }
+
+ ctx.receiveSettleAliceToBob()
+ ctx.receiveCommitSigAliceToBob(0)
+}
+
// TestChannelLinkRevocationWindowRegular asserts that htlcs paying to a regular
// invoice are settled even if the revocation window gets exhausted.
func TestChannelLinkRevocationWindowRegular(t *testing.T) {
diff --git a/invoices/invoices.go b/invoices/invoices.go
index d6d59b4..0df3fe6 100644
--- a/invoices/invoices.go
+++ b/invoices/invoices.go
@@ -3,6 +3,7 @@ package invoices
import (
"errors"
"fmt"
+ "math"
"strings"
"time"
@@ -22,6 +23,11 @@ const (
// TODO(halseth): determine the max length payment request when field
// lengths are final.
MaxPaymentRequestSize = 4096
+
+ // MaxFinalCltvDelta is the upper bound for final CLTV deltas used by
+ // invoice creation and final-hop HTLC validation. It matches
+ // routing.MaxCLTVDelta.
+ MaxFinalCltvDelta = math.MaxUint16
)
var (
diff --git a/lnrpc/invoicesrpc/addinvoice.go b/lnrpc/invoicesrpc/addinvoice.go
index 38b6836..7610552 100644
--- a/lnrpc/invoicesrpc/addinvoice.go
+++ b/lnrpc/invoicesrpc/addinvoice.go
@@ -6,7 +6,6 @@ import (
"crypto/rand"
"errors"
"fmt"
- "math"
mathRand "math/rand"
"sort"
"time"
@@ -406,10 +405,12 @@ func AddInvoice(ctx context.Context, cfg *AddInvoiceConfig,
options = append(options, zpay32.Description(invoice.Memo))
}
- if invoice.CltvExpiry > routing.MaxCLTVDelta {
+ // Final-hop invoices are limited to the same CLTV bound used by the
+ // link and contractcourt validation.
+ if invoice.CltvExpiry > invoices.MaxFinalCltvDelta {
return nil, nil, fmt.Errorf("CLTV delta of %v is too large, "+
"max accepted is: %v", invoice.CltvExpiry,
- math.MaxUint16)
+ invoices.MaxFinalCltvDelta)
}
// We'll use our current default CLTV value unless one was specified as
diff --git a/lnrpc/invoicesrpc/addinvoice_test.go b/lnrpc/invoicesrpc/addinvoice_test.go
index 2352f92..c4f402e 100644
--- a/lnrpc/invoicesrpc/addinvoice_test.go
+++ b/lnrpc/invoicesrpc/addinvoice_test.go
@@ -10,6 +10,7 @@ import (
"github.com/btcsuite/btcd/wire/v2"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/graph/db/models"
+ "github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/zpay32"
@@ -27,6 +28,24 @@ var (
pubkey = btcec.NewPublicKey(new(btcec.FieldVal).SetInt(4), pubKeyY)
)
+// TestAddInvoiceRejectsCltvAboveMaxIncoming asserts that invoice creation
+// rejects final CLTV deltas above the supported maximum.
+func TestAddInvoiceRejectsCltvAboveMaxIncoming(t *testing.T) {
+ t.Parallel()
+
+ _, _, err := AddInvoice(
+ t.Context(), &AddInvoiceConfig{}, &AddInvoiceData{
+ CltvExpiry: invoices.MaxFinalCltvDelta + 1,
+ },
+ )
+ require.ErrorContains(
+ t, err, fmt.Sprintf(
+ "max accepted is: %v",
+ invoices.MaxFinalCltvDelta,
+ ),
+ )
+}
+
type hopHintsConfigMock struct {
t *testing.T
mock.Mock
Why this scored 64/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.