Skip to content

Commit 2884de9

Browse files
committed
staticaddr/loopin: cancel orphan invoice when init fails early
If InitHtlcAction creates the private swap invoice but fails before the loop-in is stored, the retry path otherwise leaves behind a live orphan invoice. Cancel that invoice on the early error path with a detached, timeout-limited context, and reuse the same helper when tearing down the monitor path. This keeps failed initialization attempts from leaving invoices that no local swap can complete.
1 parent 3fc0128 commit 2884de9

2 files changed

Lines changed: 208 additions & 21 deletions

File tree

staticaddr/loopin/actions.go

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const (
3636
defaultConfTarget = 3
3737

3838
DefaultPaymentTimeoutSeconds = 60
39+
40+
defaultInvoiceCleanupTimeout = 5 * time.Second
3941
)
4042

4143
var (
@@ -57,6 +59,24 @@ var (
5759
func (f *FSM) InitHtlcAction(ctx context.Context,
5860
_ fsm.EventContext) fsm.EventType {
5961

62+
var event fsm.EventType
63+
invoiceNeedsCleanup := false
64+
defer func() {
65+
// If we created the private invoice but failed before persisting the
66+
// swap, cancel it so retries do not accumulate orphan invoices.
67+
if !invoiceNeedsCleanup || event != fsm.OnError {
68+
return
69+
}
70+
71+
f.cancelSwapInvoice(ctx)
72+
}()
73+
74+
returnError := func(err error) fsm.EventType {
75+
event = f.HandleError(err)
76+
77+
return event
78+
}
79+
6080
// Lock the deposits and transition them to the LoopingIn state.
6181
err := f.cfg.DepositManager.TransitionDeposits(
6282
ctx, f.loopIn.Deposits, deposit.OnLoopInInitiated,
@@ -65,7 +85,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
6585
if err != nil {
6686
err = fmt.Errorf("unable to loop-in deposits: %w", err)
6787

68-
return f.HandleError(err)
88+
return returnError(err)
6989
}
7090

7191
// Calculate the swap invoice amount. The server needs to pay us the
@@ -88,7 +108,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
88108
err = fmt.Errorf("unable to create random swap preimage: %w",
89109
err)
90110

91-
return f.HandleError(err)
111+
return returnError(err)
92112
}
93113
f.loopIn.SwapPreimage = swapPreimage
94114
f.loopIn.SwapHash = swapPreimage.Hash()
@@ -100,7 +120,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
100120
if err != nil {
101121
err = fmt.Errorf("unable to derive client htlc key: %w", err)
102122

103-
return f.HandleError(err)
123+
return returnError(err)
104124
}
105125
f.loopIn.ClientPubkey = keyDesc.PubKey
106126
f.loopIn.HtlcKeyLocator = keyDesc.KeyLocator
@@ -119,10 +139,14 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
119139
if err != nil {
120140
err = fmt.Errorf("unable to create swap invoice: %w", err)
121141

122-
return f.HandleError(err)
142+
return returnError(err)
123143
}
124144
f.loopIn.SwapInvoice = swapInvoice
125145

146+
// From here until CreateLoopIn succeeds, any error path would otherwise
147+
// leave behind a live invoice with no persisted swap to recover it.
148+
invoiceNeedsCleanup = true
149+
126150
f.loopIn.ProtocolVersion = version.AddressProtocolVersion(
127151
version.CurrentRPCProtocolVersion(),
128152
)
@@ -149,7 +173,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
149173
err = fmt.Errorf("unable to initiate the loop-in with the "+
150174
"server: %w", err)
151175

152-
return f.HandleError(err)
176+
return returnError(err)
153177
}
154178

155179
// Pushing empty sigs signals the server that we abandoned the swap
@@ -171,7 +195,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
171195
pushEmptySigs()
172196
err = fmt.Errorf("unable to parse server pubkey: %w", err)
173197

174-
return f.HandleError(err)
198+
return returnError(err)
175199
}
176200
f.loopIn.ServerPubkey = serverPubkey
177201

@@ -185,7 +209,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
185209
err = fmt.Errorf("server response parameters are outside "+
186210
"our allowed range: %w", err)
187211

188-
return f.HandleError(err)
212+
return returnError(err)
189213
}
190214

191215
f.loopIn.HtlcCltvExpiry = loopInResp.HtlcExpiry
@@ -194,23 +218,23 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
194218
pushEmptySigs()
195219
err = fmt.Errorf("unable to convert server nonces: %w", err)
196220

197-
return f.HandleError(err)
221+
return returnError(err)
198222
}
199223
f.htlcServerNoncesHighFee, err = toNonces(
200224
loopInResp.HighFeeHtlcInfo.Nonces,
201225
)
202226
if err != nil {
203227
pushEmptySigs()
204228

205-
return f.HandleError(err)
229+
return returnError(err)
206230
}
207231
f.htlcServerNoncesExtremelyHighFee, err = toNonces(
208232
loopInResp.ExtremeFeeHtlcInfo.Nonces,
209233
)
210234
if err != nil {
211235
pushEmptySigs()
212236

213-
return f.HandleError(err)
237+
return returnError(err)
214238
}
215239

216240
// We need to defend against the server setting high fees for the htlc
@@ -232,7 +256,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
232256
log.Errorf("server htlc tx fee is higher than the configured "+
233257
"allowed maximum: %v > %v", fee, maxHtlcTxFee)
234258

235-
return f.HandleError(ErrFeeTooHigh)
259+
return returnError(ErrFeeTooHigh)
236260
}
237261
f.loopIn.HtlcTxFeeRate = feeRate
238262

@@ -246,7 +270,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
246270
"configured allowed maximum: %v > %v", fee,
247271
maxHtlcTxBackupFee)
248272

249-
return f.HandleError(ErrFeeTooHigh)
273+
return returnError(ErrFeeTooHigh)
250274
}
251275
f.loopIn.HtlcTxHighFeeRate = highFeeRate
252276

@@ -262,7 +286,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
262286
"configured allowed maximum: %v > %v", fee,
263287
maxHtlcTxBackupFee)
264288

265-
return f.HandleError(ErrFeeTooHigh)
289+
return returnError(ErrFeeTooHigh)
266290
}
267291
f.loopIn.HtlcTxExtremelyHighFeeRate = extremelyHighFeeRate
268292

@@ -276,7 +300,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
276300
err = fmt.Errorf("unable to derive htlc timeout sweep "+
277301
"address: %w", err)
278302

279-
return f.HandleError(err)
303+
return returnError(err)
280304
}
281305
f.loopIn.HtlcTimeoutSweepAddress = sweepAddress
282306

@@ -286,10 +310,31 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
286310
pushEmptySigs()
287311
err = fmt.Errorf("unable to store loop-in in db: %w", err)
288312

289-
return f.HandleError(err)
313+
return returnError(err)
290314
}
291315

292-
return OnHtlcInitiated
316+
// Once the swap is stored, restart/recovery code owns invoice lifecycle.
317+
invoiceNeedsCleanup = false
318+
319+
event = OnHtlcInitiated
320+
321+
return event
322+
}
323+
324+
// cancelSwapInvoice best-effort cancels the current swap invoice using a
325+
// detached timeout-limited context so cleanup still runs even if the caller's
326+
// context is already done.
327+
func (f *FSM) cancelSwapInvoice(ctx context.Context) {
328+
cleanupCtx, cancel := context.WithTimeout(
329+
context.WithoutCancel(ctx), defaultInvoiceCleanupTimeout,
330+
)
331+
defer cancel()
332+
333+
err := f.cfg.InvoicesClient.CancelInvoice(cleanupCtx, f.loopIn.SwapHash)
334+
if err != nil {
335+
f.Warnf("unable to cancel invoice for swap %v: %v",
336+
f.loopIn.SwapHash, err)
337+
}
293338
}
294339

295340
// SignHtlcTxAction is called if the htlc was initialized and the server
@@ -557,11 +602,9 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
557602
// Cancel the lndclient invoice subscription.
558603
cancelInvoiceSubscription()
559604

560-
err = f.cfg.InvoicesClient.CancelInvoice(ctx, f.loopIn.SwapHash)
561-
if err != nil {
562-
f.Warnf("unable to cancel invoice "+
563-
"for swap hash: %v", err)
564-
}
605+
// Reuse the same helper as InitHtlcAction so timeout cleanup follows
606+
// the same detached-context path as early-init cleanup.
607+
f.cancelSwapInvoice(ctx)
565608
}
566609

567610
for {

staticaddr/loopin/actions_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,124 @@ func testValidateLoopInContract(_ int32, _ int32) error {
271271
return nil
272272
}
273273

274+
// TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice
275+
// created before a server-side rejection is canceled immediately.
276+
func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) {
277+
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
278+
defer cancel()
279+
280+
mockLnd := test.NewMockLnd()
281+
282+
loopIn := &StaticAddressLoopIn{
283+
Deposits: []*deposit.Deposit{{
284+
Value: 200_000,
285+
}},
286+
InitiationHeight: uint32(mockLnd.Height),
287+
InitiationTime: time.Now(),
288+
PaymentTimeoutSeconds: DefaultPaymentTimeoutSeconds,
289+
ProtocolVersion: version.ProtocolVersion_V0,
290+
}
291+
292+
cfg := &Config{
293+
AddressManager: &mockAddressManager{
294+
params: &address.Parameters{
295+
ProtocolVersion: version.ProtocolVersion_V0,
296+
},
297+
},
298+
DepositManager: &noopDepositManager{},
299+
WalletKit: mockLnd.WalletKit,
300+
LndClient: mockLnd.Client,
301+
InvoicesClient: mockLnd.LndServices.Invoices,
302+
Server: &initHtlcTestServer{
303+
loopInErr: errors.New("server rejected swap"),
304+
},
305+
}
306+
307+
f, err := NewFSM(ctx, loopIn, cfg, false)
308+
require.NoError(t, err)
309+
310+
// The init step should fail and synchronously trigger deferred invoice
311+
// cleanup.
312+
event := f.InitHtlcAction(ctx, nil)
313+
require.Equal(t, fsm.OnError, event)
314+
315+
select {
316+
case hash := <-mockLnd.FailInvoiceChannel:
317+
require.Equal(t, loopIn.SwapHash, hash)
318+
319+
case <-ctx.Done():
320+
t.Fatalf("invoice was not canceled: %v", ctx.Err())
321+
}
322+
}
323+
324+
// TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure verifies that the early
325+
// fee guard also cancels the pre-created invoice before returning an error.
326+
func TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure(t *testing.T) {
327+
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
328+
defer cancel()
329+
330+
mockLnd := test.NewMockLnd()
331+
serverKey, err := btcec.NewPrivateKey()
332+
require.NoError(t, err)
333+
334+
loopIn := &StaticAddressLoopIn{
335+
Deposits: []*deposit.Deposit{{
336+
Value: 200_000,
337+
}},
338+
InitiationHeight: uint32(mockLnd.Height),
339+
InitiationTime: time.Now(),
340+
PaymentTimeoutSeconds: DefaultPaymentTimeoutSeconds,
341+
ProtocolVersion: version.ProtocolVersion_V0,
342+
}
343+
344+
cfg := &Config{
345+
AddressManager: &mockAddressManager{
346+
params: &address.Parameters{
347+
ProtocolVersion: version.ProtocolVersion_V0,
348+
},
349+
},
350+
DepositManager: &noopDepositManager{},
351+
WalletKit: mockLnd.WalletKit,
352+
LndClient: mockLnd.Client,
353+
InvoicesClient: mockLnd.LndServices.Invoices,
354+
Server: &initHtlcTestServer{
355+
loopInResp: &swapserverrpc.ServerStaticAddressLoopInResponse{
356+
HtlcServerPubKey: serverKey.PubKey().
357+
SerializeCompressed(),
358+
HtlcExpiry: mockLnd.Height +
359+
DefaultLoopInOnChainCltvDelta,
360+
StandardHtlcInfo: &swapserverrpc.ServerHtlcSigningInfo{
361+
FeeRate: 1_000_000,
362+
},
363+
HighFeeHtlcInfo: &swapserverrpc.ServerHtlcSigningInfo{},
364+
ExtremeFeeHtlcInfo: &swapserverrpc.
365+
ServerHtlcSigningInfo{},
366+
},
367+
},
368+
ValidateLoopInContract: func(int32, int32) error {
369+
return nil
370+
},
371+
MaxStaticAddrHtlcFeePercentage: 0,
372+
MaxStaticAddrHtlcBackupFeePercentage: 1,
373+
}
374+
375+
f, err := NewFSM(ctx, loopIn, cfg, false)
376+
require.NoError(t, err)
377+
378+
// The fee guard runs before persistence, so the deferred cleanup must
379+
// cancel the invoice on this error path as well.
380+
event := f.InitHtlcAction(ctx, nil)
381+
require.Equal(t, fsm.OnError, event)
382+
383+
select {
384+
case hash := <-mockLnd.FailInvoiceChannel:
385+
require.Equal(t, loopIn.SwapHash, hash)
386+
387+
case <-ctx.Done():
388+
t.Fatalf("invoice was not canceled: %v", ctx.Err())
389+
}
390+
}
391+
274392
// mockAddressManager is a minimal AddressManager implementation used by the
275393
// test FSM setup.
276394
type mockAddressManager struct {
@@ -328,3 +446,29 @@ func (n *noopDepositManager) GetActiveDepositsInState(fsm.StateType) (
328446

329447
return nil, nil
330448
}
449+
450+
// initHtlcTestServer lets InitHtlcAction tests inject a deterministic server
451+
// response without standing up the full gRPC client.
452+
type initHtlcTestServer struct {
453+
swapserverrpc.StaticAddressServerClient
454+
455+
loopInResp *swapserverrpc.ServerStaticAddressLoopInResponse
456+
loopInErr error
457+
}
458+
459+
// ServerStaticAddressLoopIn returns the canned response configured by the test.
460+
func (s *initHtlcTestServer) ServerStaticAddressLoopIn(context.Context,
461+
*swapserverrpc.ServerStaticAddressLoopInRequest, ...grpc.CallOption,
462+
) (*swapserverrpc.ServerStaticAddressLoopInResponse, error) {
463+
464+
return s.loopInResp, s.loopInErr
465+
}
466+
467+
// PushStaticAddressHtlcSigs accepts the abandonment signal used by error-path
468+
// tests without adding additional assertions.
469+
func (s *initHtlcTestServer) PushStaticAddressHtlcSigs(context.Context,
470+
*swapserverrpc.PushStaticAddressHtlcSigsRequest, ...grpc.CallOption,
471+
) (*swapserverrpc.PushStaticAddressHtlcSigsResponse, error) {
472+
473+
return &swapserverrpc.PushStaticAddressHtlcSigsResponse{}, nil
474+
}

0 commit comments

Comments
 (0)