Skip to content

Commit 722e76f

Browse files
committed
staticaddr/loopin: support unconfirmed deposits
Allow static loop-ins to select unconfirmed deposits, persist the original selected outpoints, validate that those outpoints are still available before HTLC signing, and align autoloop expiry ordering with static loop-in selection.
1 parent 77f0288 commit 722e76f

16 files changed

Lines changed: 615 additions & 57 deletions

loopd/daemon.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
700700
Store: staticAddressLoopInStore,
701701
WalletKit: d.lnd.WalletKit,
702702
ChainNotifier: d.lnd.ChainNotifier,
703+
TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client),
703704
NotificationManager: notificationManager,
704705
ChainParams: d.lnd.ChainParams,
705706
Signer: d.lnd.Signer,

staticaddr/deposit/manager.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@ import (
1818
)
1919

2020
const (
21-
// MinConfs is the minimum number of confirmations we require for a
22-
// deposit to be considered available for loop-ins, coop-spends and
23-
// timeouts.
21+
// MinConfs is the legacy minimum confirmation target deposits had to
22+
// reach before they were considered ready to be used for swaps.
2423
MinConfs = 6
2524

2625
// MaxConfs is unset since we don't require a max number of

staticaddr/loopin/actions.go

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
1313
"github.com/btcsuite/btcd/btcutil"
1414
"github.com/btcsuite/btcd/txscript"
15+
"github.com/btcsuite/btcd/wire"
1516
"github.com/btcsuite/btcwallet/chain"
1617
"github.com/lightninglabs/lndclient"
1718
"github.com/lightninglabs/loop"
@@ -324,7 +325,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
324325
// cancelSwapInvoice best-effort cancels the current swap invoice using a
325326
// detached timeout-limited context.
326327
func (f *FSM) cancelSwapInvoice() {
327-
if f.loopIn.SwapInvoice == "" {
328+
if f.loopIn.SwapHash == (lntypes.Hash{}) {
328329
return
329330
}
330331

@@ -340,6 +341,68 @@ func (f *FSM) cancelSwapInvoice() {
340341
}
341342
}
342343

344+
// handleInvoiceUpdate applies the monitor state's invoice-update semantics and
345+
// reports whether the update produced a terminal event.
346+
func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
347+
fsm.EventType, bool) {
348+
349+
switch update.State {
350+
case invoices.ContractOpen:
351+
return fsm.NoOp, false
352+
353+
case invoices.ContractAccepted:
354+
return fsm.NoOp, false
355+
356+
case invoices.ContractSettled:
357+
f.Debugf("received off-chain payment update %v", update.State)
358+
return OnPaymentReceived, true
359+
360+
case invoices.ContractCanceled:
361+
// If the invoice was canceled we only log here since we still need
362+
// to monitor until the htlc timed out.
363+
log.Warnf("invoice for swap hash %v canceled", f.loopIn.SwapHash)
364+
return fsm.NoOp, false
365+
366+
default:
367+
err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+
368+
"canceled", update.State, f.loopIn.SwapHash)
369+
return f.HandleError(err), true
370+
}
371+
}
372+
373+
// originalDepositOutpointUnavailable checks the original selected deposit
374+
// outpoints against the chain backend's UTXO view.
375+
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
376+
bool, error) {
377+
378+
if f.cfg.TxOutChecker == nil {
379+
return false, nil
380+
}
381+
382+
const includeMempool = true
383+
for _, outpointStr := range f.loopIn.DepositOutpoints {
384+
outpoint, err := wire.NewOutPointFromString(outpointStr)
385+
if err != nil {
386+
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
387+
outpointStr, err)
388+
}
389+
390+
txOut, err := f.cfg.TxOutChecker.GetTxOut(
391+
ctx, *outpoint, includeMempool,
392+
)
393+
if err != nil {
394+
return false, fmt.Errorf("unable to get txout %v: %w",
395+
outpoint, err)
396+
}
397+
398+
if txOut == nil {
399+
return true, nil
400+
}
401+
}
402+
403+
return false, nil
404+
}
405+
343406
// SignHtlcTxAction is called if the htlc was initialized and the server
344407
// provided the necessary information to construct the htlc tx. We sign the htlc
345408
// tx and send the signatures to the server.
@@ -348,6 +411,18 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
348411

349412
var err error
350413

414+
outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx)
415+
if err != nil {
416+
return f.HandleError(err)
417+
}
418+
if outpointUnavailable {
419+
err = errors.New("original deposit outpoint no longer available")
420+
f.Warnf("%v, canceling swap invoice", err)
421+
f.cancelSwapInvoice(ctx)
422+
423+
return f.HandleError(err)
424+
}
425+
351426
f.loopIn.AddressParams, err =
352427
f.cfg.AddressManager.GetStaticAddressParameters(ctx)
353428

@@ -717,32 +792,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
717792

718793
return f.HandleError(err)
719794

720-
case update := <-invoiceUpdateChan:
721-
switch update.State {
722-
case invoices.ContractOpen:
723-
case invoices.ContractAccepted:
724-
case invoices.ContractSettled:
725-
f.Debugf("received off-chain payment update "+
726-
"%v", update.State)
727-
728-
return OnPaymentReceived
729-
730-
case invoices.ContractCanceled:
731-
// If the invoice was canceled we only log here
732-
// since we still need to monitor until the htlc
733-
// timed out.
734-
log.Warnf("invoice for swap hash %v canceled",
735-
f.loopIn.SwapHash)
795+
case update, ok := <-invoiceUpdateChan:
796+
if !ok {
797+
invoiceUpdateChan = nil
798+
continue
799+
}
736800

737-
default:
738-
err = fmt.Errorf("unexpected invoice state %v "+
739-
"for swap hash %v canceled",
740-
update.State, f.loopIn.SwapHash)
801+
if event, done := f.handleInvoiceUpdate(update); done {
802+
return event
803+
}
741804

742-
return f.HandleError(err)
805+
case err, ok := <-invoiceErrChan:
806+
if !ok {
807+
invoiceErrChan = nil
808+
continue
743809
}
744810

745-
case err = <-invoiceErrChan:
746811
f.Errorf("invoice subscription error: %v", err)
747812

748813
case <-ctx.Done():

staticaddr/loopin/actions_test.go

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) {
5454
loopIn.SetState(MonitorInvoiceAndHtlcTx)
5555

5656
// Seed the mock invoice store so LookupInvoice succeeds.
57-
mockLnd.Invoices[swapHash] = &lndclient.Invoice{
57+
mockLnd.SetInvoice(&lndclient.Invoice{
5858
Hash: swapHash,
5959
State: invoices.ContractOpen,
60-
}
60+
})
6161

6262
cfg := &Config{
6363
AddressManager: &mockAddressManager{
@@ -270,6 +270,133 @@ func testValidateLoopInContract(_ int32, _ int32) error {
270270
return nil
271271
}
272272

273+
// TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a
274+
// present txout does not trigger the RBF cancellation path.
275+
func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) {
276+
originalOutpoint := wire.OutPoint{
277+
Hash: chainhash.Hash{1},
278+
Index: 0,
279+
}
280+
281+
txOutChecker := &testTxOutChecker{
282+
txOut: &wire.TxOut{Value: 10_000},
283+
}
284+
f := &FSM{
285+
cfg: &Config{
286+
TxOutChecker: txOutChecker,
287+
},
288+
loopIn: &StaticAddressLoopIn{
289+
DepositOutpoints: []string{originalOutpoint.String()},
290+
},
291+
}
292+
293+
unavailable, err := f.originalDepositOutpointUnavailable(t.Context())
294+
require.NoError(t, err)
295+
require.False(t, unavailable)
296+
require.Equal(t, []wire.OutPoint{originalOutpoint}, txOutChecker.outpoints)
297+
require.Equal(t, []bool{true}, txOutChecker.includeMempool)
298+
}
299+
300+
// TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a
301+
// pending loop-in is canceled before HTLC signing if GetTxOut with mempool
302+
// awareness reports that one of the originally selected outpoints is gone.
303+
func TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable(t *testing.T) {
304+
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
305+
defer cancel()
306+
307+
mockLnd := test.NewMockLnd()
308+
309+
swapHash := lntypes.Hash{9, 8, 7}
310+
originalOutpoint := wire.OutPoint{
311+
Hash: chainhash.Hash{1},
312+
Index: 0,
313+
}
314+
315+
loopIn := &StaticAddressLoopIn{
316+
SwapHash: swapHash,
317+
DepositOutpoints: []string{originalOutpoint.String()},
318+
}
319+
320+
txOutChecker := &testTxOutChecker{}
321+
cfg := &Config{
322+
AddressManager: &mockAddressManager{
323+
params: &script.Parameters{
324+
ProtocolVersion: version.ProtocolVersion_V0,
325+
},
326+
},
327+
InvoicesClient: mockLnd.LndServices.Invoices,
328+
TxOutChecker: txOutChecker,
329+
}
330+
331+
f, err := NewFSM(ctx, loopIn, cfg, false)
332+
require.NoError(t, err)
333+
334+
event := f.SignHtlcTxAction(ctx, nil)
335+
require.Equal(t, fsm.OnError, event)
336+
require.ErrorContains(
337+
t, f.LastActionError, "original deposit outpoint no longer available",
338+
)
339+
340+
select {
341+
case hash := <-mockLnd.FailInvoiceChannel:
342+
require.Equal(t, swapHash, hash)
343+
case <-ctx.Done():
344+
t.Fatalf("invoice was not canceled: %v", ctx.Err())
345+
}
346+
347+
require.Equal(t, []wire.OutPoint{originalOutpoint}, txOutChecker.outpoints)
348+
require.Equal(t, []bool{true}, txOutChecker.includeMempool)
349+
}
350+
351+
// TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup
352+
// failures are treated as errors, but do not cancel the invoice. The invoice is
353+
// only canceled when GetTxOut explicitly returns nil for an original outpoint.
354+
func TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError(t *testing.T) {
355+
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
356+
defer cancel()
357+
358+
mockLnd := test.NewMockLnd()
359+
360+
swapHash := lntypes.Hash{9, 8, 6}
361+
originalOutpoint := wire.OutPoint{
362+
Hash: chainhash.Hash{3},
363+
Index: 0,
364+
}
365+
366+
loopIn := &StaticAddressLoopIn{
367+
SwapHash: swapHash,
368+
DepositOutpoints: []string{originalOutpoint.String()},
369+
}
370+
371+
txOutChecker := &testTxOutChecker{
372+
err: errors.New("backend unavailable"),
373+
}
374+
cfg := &Config{
375+
AddressManager: &mockAddressManager{
376+
params: &script.Parameters{
377+
ProtocolVersion: version.ProtocolVersion_V0,
378+
},
379+
},
380+
InvoicesClient: mockLnd.LndServices.Invoices,
381+
TxOutChecker: txOutChecker,
382+
}
383+
384+
f, err := NewFSM(ctx, loopIn, cfg, false)
385+
require.NoError(t, err)
386+
387+
event := f.SignHtlcTxAction(ctx, nil)
388+
require.Equal(t, fsm.OnError, event)
389+
require.ErrorContains(
390+
t, f.LastActionError, "unable to get txout",
391+
)
392+
393+
select {
394+
case hash := <-mockLnd.FailInvoiceChannel:
395+
t.Fatalf("invoice should not have been canceled: %x", hash)
396+
default:
397+
}
398+
}
399+
273400
// TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice
274401
// created before a server-side rejection is canceled immediately.
275402
func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) {
@@ -541,6 +668,24 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context,
541668
return r.err
542669
}
543670

671+
type testTxOutChecker struct {
672+
txOut *wire.TxOut
673+
err error
674+
675+
outpoints []wire.OutPoint
676+
includeMempool []bool
677+
}
678+
679+
// GetTxOut records lookup parameters and returns the configured result.
680+
func (t *testTxOutChecker) GetTxOut(_ context.Context,
681+
outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) {
682+
683+
t.outpoints = append(t.outpoints, outpoint)
684+
t.includeMempool = append(t.includeMempool, includeMempool)
685+
686+
return t.txOut, t.err
687+
}
688+
544689
// initHtlcTestServer lets InitHtlcAction tests inject a deterministic server
545690
// response without standing up the full gRPC client.
546691
type initHtlcTestServer struct {

staticaddr/loopin/autoloop_dp.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,10 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount,
246246
continue
247247
}
248248

249-
residualLife := candidateDeposit.ConfirmationHeight +
250-
int64(csvExpiry) - int64(blockHeight)
249+
residualLife := int64(blocksUntilDepositExpiry(
250+
uint32(candidateDeposit.ConfirmationHeight),
251+
blockHeight, csvExpiry,
252+
))
251253

252254
eligibleDeposits = append(
253255
eligibleDeposits, autoloopCandidateDeposit{

staticaddr/loopin/autoloop_dp_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,28 @@ func TestSelectNoChangeDepositsWithMemoryBudget(t *testing.T) {
8080
}
8181
}
8282

83+
// TestSelectNoChangeDepositsPrefersConfirmedTie verifies unconfirmed deposits
84+
// are not treated as earlier-expiring than confirmed deposits. Their CSV timer
85+
// has not started yet, so a same-value confirmed deposit should win the expiry
86+
// tie-break.
87+
func TestSelectNoChangeDepositsPrefersConfirmedTie(t *testing.T) {
88+
t.Parallel()
89+
90+
unconfirmed := makeDeposit(34, 0, 5_000, 0)
91+
confirmed := makeDeposit(35, 0, 5_000, 200)
92+
93+
deposits, err := selectNoChangeDeposits(
94+
5_000, 5_000, []*deposit.Deposit{
95+
unconfirmed, confirmed,
96+
}, 1_000, 100, nil,
97+
)
98+
require.NoError(t, err)
99+
require.Equal(
100+
t, []string{confirmed.OutPoint.String()},
101+
depositOutpoints(deposits),
102+
)
103+
}
104+
83105
// TestAutoloopDPSizing verifies the bucket sizing math. These cases are easier
84106
// to understand directly than by inferring the step from a larger selector
85107
// behavior test.

0 commit comments

Comments
 (0)