Merge pull request #11198 from gijswijs/amp-setlocal-reconstruction-fix
What changed, and why it matters
This change fixes a bug in LND's handling of AMP (Atomic Multi-Path) payments. Previously, if one partial payment set failed to reconstruct its secret preimage, the entire invoice was canceled, including unrelated partial payment sets from other payers. Now only the failing set is canceled, and the invoice stays open so other valid sets can still complete and settle. This is a correctness/availability fix for reusable AMP invoices.
Backport to affected release branches and deploy promptly for nodes accepting reusable static AMP invoices. No immediate incident response beyond patching is indicated by the commit materials.
Security signals we found
Denial-of-service against reusable static AMP invoices by causing unrelated accepted HTLC sets to be canceled
Incorrect invoice state transition from set-local failure to global invoice cancellation
Availability impact on multi-payer AMP invoice scenarios
Evidence from the diff
In invoices/update.go, updateMpp previously set CancelInvoiceUpdate with ContractCanceled when reconstructAMPPreimages returned a failure. The patch changes this to CancelHTLCsUpdate, canceling only the HTLCs in the affected htlcSet and preserving the invoice state. A new regression test verifies that a reconstruction failure for payer B’s set does not cancel payer A’s concurrently accepted set, and that payer A’s set can still settle afterward. Release notes for 0.20.5 and 0.21.4 describe the fix as preventing AMP reconstruction failure from canceling the entire invoice.
Changed components
invoices/update.goAMP invoice reconstruction logicInvoice registry HTLC cancellation pathInspect captured patch +155 / −4
### docs/release-notes/release-notes-0.20.5.md
@@ -27,6 +27,12 @@
the replay from being handled as a second interception while the original
outgoing HTLC remains active.
+* [Fixed AMP reconstruction failure canceling the entire
+ invoice](https://github.com/lightningnetwork/lnd/pull/11198). An AMP set
+ that fails preimage reconstruction now only cancels the HTLCs of that set,
+ keeping the invoice open so that other accepted sets on reusable static
+ AMP invoices remain payable.
+
# New Features
## Functional Enhancements
@@ -74,3 +80,4 @@
# Contributors (Alphabetical Order)
* elsirion
+* Gijs van Dam
### docs/release-notes/release-notes-0.21.4.md
@@ -32,6 +32,12 @@
with empty features. The migration now uses the regular graph reader's
existing feature-format compatibility handling.
+* [Fixed AMP reconstruction failure canceling the entire
+ invoice](https://github.com/lightningnetwork/lnd/pull/11198). An AMP set
+ that fails preimage reconstruction now only cancels the HTLCs of that set,
+ keeping the invoice open so that other accepted sets on reusable static
+ AMP invoices remain payable.
+
# New Features
## Functional Enhancements
@@ -91,5 +97,6 @@
* Andras Banki-Horvath
* elsirion
+* Gijs van Dam
* Olaoluwa Osuntokun
* Ziggie
### invoices/invoiceregistry_test.go
@@ -118,6 +118,10 @@ func TestInvoiceRegistry(t *testing.T) {
name: "CancelAMPInvoicePendingHTLCs",
test: testCancelAMPInvoicePendingHTLCs,
},
+ {
+ name: "AmpReconstructionFailCancelsSetOnly",
+ test: testAmpReconstructionFailCancelsSetOnly,
+ },
}
makeKeyValueDB := func(t *testing.T) (invpkg.InvoiceDB,
@@ -2607,3 +2611,122 @@ func testCancelAMPInvoicePendingHTLCs(t *testing.T,
"expected HTLC to be canceled")
}
}
+
+// testAmpReconstructionFailCancelsSetOnly tests that an AMP set which fails
+// reconstruction only cancels that set: the invoice stays open and
+// concurrently accepted sets from other payers remain intact and settleable.
+func testAmpReconstructionFailCancelsSetOnly(t *testing.T,
+ makeDB func(t *testing.T) (invpkg.InvoiceDB, *clock.TestClock)) {
+
+ t.Parallel()
+ defer timeout()()
+
+ ctx := newTestContext(t, nil, makeDB)
+ ctxb := t.Context()
+
+ const expiry = uint32(testCurrentHeight + 20)
+
+ var payAddr [32]byte
+ _, err := rand.Read(payAddr[:])
+ require.NoError(t, err)
+
+ // Create a reusable static AMP invoice.
+ ampInvoice := newInvoice(t, false, true)
+ ampInvoice.Terms.PaymentAddr = payAddr
+
+ _, err = ctx.registry.AddInvoice(
+ ctxb, ampInvoice, testInvoicePaymentHash,
+ )
+ require.NoError(t, err)
+
+ // Payer A starts a two-shard payment under setID A with valid AMP
+ // shares.
+ var sharer amp.Sharer
+ sharer, err = amp.NewSeedSharer()
+ require.NoError(t, err)
+
+ left, sharer, err := sharer.Split()
+ require.NoError(t, err)
+
+ var setIDA [32]byte
+ _, err = rand.Read(setIDA[:])
+ require.NoError(t, err)
+
+ childA0 := left.Child(0)
+ childA1 := sharer.Child(1)
+
+ hodlChanA0 := make(chan interface{}, 1)
+ payloadA0 := &mockPayload{
+ mpp: record.NewMPP(testInvoiceAmount, payAddr),
+ amp: record.NewAMP(childA0.Share, setIDA, 0),
+ }
+
+ // The first shard is incomplete, so it is accepted and hodl'd without
+ // any reconstruction attempt.
+ res, err := ctx.registry.NotifyExitHopHtlc(
+ childA0.Hash, testInvoiceAmount/2, expiry, testCurrentHeight,
+ getCircuitKey(1), hodlChanA0, nil, payloadA0,
+ )
+ require.NoError(t, err)
+ require.Nil(t, res, "payer A partial HTLC should be hodl'd")
+
+ // Payer B sends a complete-value set under a different setID with a
+ // blank root share, which fails reconstruction. This must only fail
+ // that set, not cancel the invoice.
+ var setIDB [32]byte
+ _, err = rand.Read(setIDB[:])
+ require.NoError(t, err)
+
+ payloadB := &mockPayload{
+ mpp: record.NewMPP(testInvoiceAmount, payAddr),
+ amp: record.NewAMP([32]byte{}, setIDB, 0),
+ }
+
+ res, err = ctx.registry.NotifyExitHopHtlc(
+ lntypes.Hash{2}, testInvoiceAmount, expiry, testCurrentHeight,
+ getCircuitKey(2), nil, nil, payloadB,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, res, "invalid HTLC should fail directly")
+ checkFailResolution(t, res, invpkg.ResultAmpReconstruction)
+
+ // The invoice must remain open, and payer A's accepted HTLC must be
+ // untouched.
+ inv, err := ctx.registry.LookupInvoice(ctxb, testInvoicePaymentHash)
+ require.NoError(t, err)
+ require.Equal(t, invpkg.ContractOpen, inv.State,
+ "invoice must stay open on set-local reconstruction failure")
+
+ htlcA0, ok := inv.Htlcs[getCircuitKey(1)]
+ require.True(t, ok)
+ require.Equal(t, invpkg.HtlcStateAccepted, htlcA0.State,
+ "concurrent set's HTLC must remain accepted")
+
+ // Payer A's set can still complete and settle.
+ payloadA1 := &mockPayload{
+ mpp: record.NewMPP(testInvoiceAmount, payAddr),
+ amp: record.NewAMP(childA1.Share, setIDA, 1),
+ }
+
+ res, err = ctx.registry.NotifyExitHopHtlc(
+ childA1.Hash, testInvoiceAmount/2, expiry, testCurrentHeight,
+ getCircuitKey(3), nil, nil, payloadA1,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, res)
+ checkSettleResolution(t, res, childA1.Preimage)
+
+ // The first shard of payer A's set is settled as well.
+ resolution, ok := (<-hodlChanA0).(invpkg.HtlcResolution)
+ require.True(t, ok)
+ require.NotNil(t, resolution)
+ checkSettleResolution(t, resolution, childA0.Preimage)
+
+ inv, err = ctx.registry.LookupInvoice(ctxb, testInvoicePaymentHash)
+ require.NoError(t, err)
+ require.Equal(t, invpkg.ContractOpen, inv.State,
+ "AMP invoice remains open after settling a set")
+ require.Equal(
+ t, invpkg.HtlcStateSettled, inv.AMPState[setIDA].State,
+ )
+}
### invoices/update.go
@@ -335,11 +335,25 @@ func updateMpp(ctx *invoiceUpdateCtx, inv *Invoice) (*InvoiceUpdateDesc,
var failRes *HtlcFailResolution
htlcPreimages, failRes = reconstructAMPPreimages(ctx, htlcSet)
if failRes != nil {
- update.UpdateType = CancelInvoiceUpdate
- update.State = &InvoiceStateUpdateDesc{
- NewState: ContractCanceled,
- SetID: setID,
+ // Reconstruction failure is a set-local condition: only
+ // the HTLCs of this set can never settle. Cancel just
+ // this set and keep the invoice open, since reusable
+ // static AMP invoices may carry other accepted sets
+ // that are unaffected by the failure. The current HTLC
+ // is failed directly via failRes and was never added
+ // to the invoice, so it is not part of the cancel set.
+ cancelHtlcs := make(
+ map[CircuitKey]struct{}, len(htlcSet),
+ )
+ for key := range htlcSet {
+ cancelHtlcs[key] = struct{}{}
}
+
+ update.UpdateType = CancelHTLCsUpdate
+ update.CancelHtlcs = cancelHtlcs
+ update.AddHtlcs = nil
+ update.SetID = (*SetID)(setID)
+
return &update, failRes, nil
}
Why this scored 60/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.