What changed, and why it matters
This commit tightens validation in LND's payment database so that a single payment cannot mix regular (non-blinded) and blinded routing attempts. Before this change, the code already rejected MPP records inside blinded payments, but it did not explicitly reject a plain legacy attempt being registered alongside a blinded one. The commit message says such a mix was only 'theoretically possible' because other checks prevent legacy payments from being split into shards. The patch adds an explicit error and many unit tests, making the safeguard more robust but not clearly fixing an actively exploitable vulnerability.
Treat as a hardening patch. Reviewers should confirm that no code path can actually register a mixed attempt today, and consider whether the new error needs to be handled by callers that retry or split payments. No urgent security response is indicated by the commit materials alone.
Security signals we found
New defensive validation preventing mixed blinded/non-blinded HTLC attempts in the same payment
Addition of a new sentinel error for mixed blinded and non-blinded payments
Extensive unit-test coverage added for verifyAttempt edge cases
Commit message describes the issue as 'theoretically possible' and already mitigated by other checks
Evidence from the diff
The change is in payments/db/payment.go’s verifyAttempt. It now inspects each in-flight HTLC’s final hop for EncryptedData (the blinded-path marker) and compares it against the new attempt’s blinded flag. If they differ, it returns the new ErrMixedBlindedAndNonBlindedPayments. The commit also adds comprehensive tests covering non-MPP, MPP, and blinded transitions. The actual logic change is small (+6 lines of production code) and is framed by the author as a hardening measure against a corner case that other checks already make unlikely.
Changed components
lnd/payments/db/payment.go - verifyAttempt validationlnd/payments/db/errors.go - new ErrMixedBlindedAndNonBlindedPaymentslnd/payments/db/payment_test.go - new verifyAttempt testsInspect captured patch +273 / −0
diff --git a/payments/db/errors.go b/payments/db/errors.go
index 40e37d9..6d5bd21 100644
--- a/payments/db/errors.go
+++ b/payments/db/errors.go
@@ -78,6 +78,12 @@ var (
ErrBlindedPaymentTotalAmountMismatch = errors.New("blinded path " +
"total amount mismatch")
+ // ErrMixedBlindedAndNonBlindedPayments is returned if we try to
+ // register a non-blinded attempt to a payment which uses a blinded
+ // paths or vice versa.
+ ErrMixedBlindedAndNonBlindedPayments = errors.New("mixed blinded and " +
+ "non-blinded payments")
+
// ErrMPPPaymentAddrMismatch is returned if we try to register an MPP
// shard where the payment address doesn't match existing shards.
ErrMPPPaymentAddrMismatch = errors.New("payment address mismatch")
diff --git a/payments/db/payment.go b/payments/db/payment.go
index 9b7fe66..147ccdb 100644
--- a/payments/db/payment.go
+++ b/payments/db/payment.go
@@ -755,6 +755,7 @@ func verifyAttempt(payment *MPPayment, attempt *HTLCAttemptInfo) error {
for _, h := range payment.InFlightHTLCs() {
hMpp := h.Route.FinalHop().MPP
+ hBlinded := len(h.Route.FinalHop().EncryptedData) != 0
// If this is a blinded payment, then no existing HTLCs
// should have MPP records.
@@ -762,6 +763,13 @@ func verifyAttempt(payment *MPPayment, attempt *HTLCAttemptInfo) error {
return ErrMPPRecordInBlindedPayment
}
+ // If the payment is blinded (previous attempts used blinded
+ // paths) and the attempt is not, or vice versa, return an
+ // error.
+ if isBlinded != hBlinded {
+ return ErrMixedBlindedAndNonBlindedPayments
+ }
+
// If this is a blinded payment, then we just need to
// check that the TotalAmtMsat field for this shard
// is equal to that of any other shard in the same
diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go
index 534a1b1..a7369c1 100644
--- a/payments/db/payment_test.go
+++ b/payments/db/payment_test.go
@@ -1178,6 +1178,265 @@ func makeAttemptInfo(total, amtForwarded int) HTLCAttemptInfo {
}
}
+// lastHopArgs is a helper struct that holds the arguments for the last hop
+// when creating an attempt with a route with a single hop (last hop).
+type lastHopArgs struct {
+ amt lnwire.MilliSatoshi
+ total lnwire.MilliSatoshi
+ mpp *record.MPP
+ encrypted []byte
+}
+
+// makeLastHopAttemptInfo creates an HTLCAttemptInfo with a route with a single
+// hop (last hop).
+func makeLastHopAttemptInfo(id uint64, args lastHopArgs) HTLCAttemptInfo {
+ lastHop := &route.Hop{
+ PubKeyBytes: vertex,
+ ChannelID: 1,
+ AmtToForward: args.amt,
+ MPP: args.mpp,
+ EncryptedData: args.encrypted,
+ TotalAmtMsat: args.total,
+ }
+
+ return HTLCAttemptInfo{
+ AttemptID: id,
+ Route: route.Route{
+ SourcePubKey: vertex,
+ TotalAmount: args.amt,
+ Hops: []*route.Hop{lastHop},
+ },
+ }
+}
+
+// makePayment creates an MPPayment with set of attempts.
+func makePayment(total lnwire.MilliSatoshi,
+ attempts ...HTLCAttempt) *MPPayment {
+
+ return &MPPayment{
+ Info: &PaymentCreationInfo{
+ Value: total,
+ },
+ HTLCs: attempts,
+ }
+}
+
+// TestVerifyAttemptNonMPPAmountMismatch tests that we return an error if the
+// attempted amount doesn't match the payment amount.
+func TestVerifyAttemptNonMPPAmountMismatch(t *testing.T) {
+ t.Parallel()
+
+ payment := makePayment(1000)
+ attempt := makeLastHopAttemptInfo(1, lastHopArgs{amt: 900})
+
+ require.ErrorIs(t, verifyAttempt(payment, &attempt), ErrValueMismatch)
+}
+
+// TestVerifyAttemptNonMPPSuccess tests that we don't return an error if the
+// attempted amount matches the payment amount.
+func TestVerifyAttemptNonMPPSuccess(t *testing.T) {
+ t.Parallel()
+
+ payment := makePayment(1200)
+ attempt := makeLastHopAttemptInfo(1, lastHopArgs{amt: 1200})
+
+ require.NoError(t, verifyAttempt(payment, &attempt))
+}
+
+// TestVerifyAttemptMPPTransitionErrors tests cases where we cannot transition
+// from a non-MPP payment to an MPP payment or vice versa.
+func TestVerifyAttemptMPPTransitionErrors(t *testing.T) {
+ t.Parallel()
+
+ total := lnwire.MilliSatoshi(2000)
+ mpp := record.NewMPP(total, testHash)
+
+ paymentWithMPP := makePayment(
+ total,
+ HTLCAttempt{
+ HTLCAttemptInfo: makeLastHopAttemptInfo(
+ 1,
+ lastHopArgs{amt: 1000, mpp: mpp},
+ ),
+ },
+ )
+ nonMPP := makeLastHopAttemptInfo(2, lastHopArgs{amt: 1000})
+ require.ErrorIs(t, verifyAttempt(paymentWithMPP, &nonMPP), ErrMPPayment)
+
+ paymentWithNonMPP := makePayment(
+ total,
+ HTLCAttempt{
+ HTLCAttemptInfo: makeLastHopAttemptInfo(
+ 1,
+ lastHopArgs{amt: total},
+ ),
+ },
+ )
+ mppAttempt := makeLastHopAttemptInfo(
+ 2, lastHopArgs{amt: 1000, mpp: mpp},
+ )
+ require.ErrorIs(
+ t,
+ verifyAttempt(paymentWithNonMPP, &mppAttempt),
+ ErrNonMPPayment,
+ )
+}
+
+// TestVerifyAttemptMPPOptionMismatch tests that we return an error if the
+// MPP options don't match the payment options.
+func TestVerifyAttemptMPPOptionMismatch(t *testing.T) {
+ t.Parallel()
+
+ total := lnwire.MilliSatoshi(3000)
+ goodMPP := record.NewMPP(total, testHash)
+ payment := makePayment(
+ total,
+ HTLCAttempt{
+ HTLCAttemptInfo: makeLastHopAttemptInfo(
+ 1,
+ lastHopArgs{amt: 1500, mpp: goodMPP},
+ ),
+ },
+ )
+
+ badAddr := record.NewMPP(total, rev)
+ attemptBadAddr := makeLastHopAttemptInfo(
+ 2,
+ lastHopArgs{amt: 1500, mpp: badAddr},
+ )
+ require.ErrorIs(
+ t,
+ verifyAttempt(payment, &attemptBadAddr),
+ ErrMPPPaymentAddrMismatch,
+ )
+
+ badTotal := record.NewMPP(total-1, testHash)
+ attemptBadTotal := makeLastHopAttemptInfo(
+ 3,
+ lastHopArgs{amt: 1500, mpp: badTotal},
+ )
+ require.ErrorIs(
+ t,
+ verifyAttempt(payment, &attemptBadTotal),
+ ErrMPPTotalAmountMismatch,
+ )
+
+ matching := makeLastHopAttemptInfo(
+ 4,
+ lastHopArgs{amt: 1500, mpp: record.NewMPP(total, testHash)},
+ )
+ require.NoError(t, verifyAttempt(payment, &matching))
+}
+
+// TestVerifyAttemptBlindedValidation tests that we return an error if we try
+// to register an MPP attempt for a blinded payment.
+func TestVerifyAttemptBlindedValidation(t *testing.T) {
+ t.Parallel()
+
+ total := lnwire.MilliSatoshi(5000)
+
+ // Payment with a blinded attempt.
+ existing := makeLastHopAttemptInfo(
+ 1,
+ lastHopArgs{amt: 2500, total: total, encrypted: []byte{1}},
+ )
+ payment := makePayment(
+ total,
+ HTLCAttempt{HTLCAttemptInfo: existing},
+ )
+
+ // Attempt with a normal MPP record should fail because a payment
+ // cannot have a mix of blinded and non-blinded attempts.
+ goodMPP := makeLastHopAttemptInfo(
+ 2,
+ lastHopArgs{amt: 2500, mpp: record.NewMPP(total, testHash)},
+ )
+ require.ErrorIs(
+ t, verifyAttempt(payment, &goodMPP),
+ ErrMixedBlindedAndNonBlindedPayments,
+ )
+
+ blindedMPP := makeLastHopAttemptInfo(
+ 2,
+ lastHopArgs{
+ amt: 2500,
+ total: total,
+ mpp: record.NewMPP(total, testHash),
+ encrypted: []byte{2},
+ },
+ )
+ require.ErrorIs(
+ t,
+ verifyAttempt(payment, &blindedMPP),
+ ErrMPPRecordInBlindedPayment,
+ )
+
+ mismatchedTotal := makeLastHopAttemptInfo(
+ 3,
+ lastHopArgs{amt: 2500, total: total + 1, encrypted: []byte{3}},
+ )
+ require.ErrorIs(
+ t,
+ verifyAttempt(payment, &mismatchedTotal),
+ ErrBlindedPaymentTotalAmountMismatch,
+ )
+
+ matching := makeLastHopAttemptInfo(
+ 4,
+ lastHopArgs{amt: 2500, total: total, encrypted: []byte{4}},
+ )
+ require.NoError(t, verifyAttempt(payment, &matching))
+}
+
+// TestVerifyAttemptBlindedMixedWithNonBlinded tests that we return an error if
+// we try to register a non-MPP attempt for a blinded payment.
+func TestVerifyAttemptBlindedMixedWithNonBlinded(t *testing.T) {
+ t.Parallel()
+
+ total := lnwire.MilliSatoshi(4000)
+
+ // Payment with a blinded attempt.
+ existing := makeLastHopAttemptInfo(
+ 1,
+ lastHopArgs{amt: 2000, total: total, encrypted: []byte{1}},
+ )
+ payment := makePayment(
+ total,
+ HTLCAttempt{HTLCAttemptInfo: existing},
+ )
+
+ partial := makeLastHopAttemptInfo(2, lastHopArgs{amt: 2000})
+ require.ErrorIs(
+ t,
+ verifyAttempt(payment, &partial),
+ ErrMixedBlindedAndNonBlindedPayments,
+ )
+
+ full := makeLastHopAttemptInfo(3, lastHopArgs{amt: total})
+ require.ErrorIs(
+ t,
+ verifyAttempt(payment, &full),
+ ErrMixedBlindedAndNonBlindedPayments,
+ )
+}
+
+// TestVerifyAttemptAmountExceedsTotal tests that we return an error if the
+// attempted amount exceeds the payment amount.
+func TestVerifyAttemptAmountExceedsTotal(t *testing.T) {
+ t.Parallel()
+
+ total := lnwire.MilliSatoshi(1000)
+ mpp := record.NewMPP(total, testHash)
+ existing := makeLastHopAttemptInfo(1, lastHopArgs{amt: 800, mpp: mpp})
+ payment := makePayment(
+ total,
+ HTLCAttempt{HTLCAttemptInfo: existing},
+ )
+
+ attempt := makeLastHopAttemptInfo(2, lastHopArgs{amt: 300, mpp: mpp})
+ require.ErrorIs(t, verifyAttempt(payment, &attempt), ErrValueExceedsAmt)
+}
+
// TestEmptyRoutesGenerateSphinxPacket tests that the generateSphinxPacket
// function is able to gracefully handle being passed a nil set of hops for the
// route by the caller.
Why this scored 44/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.