diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 5aa8195f7..3d13e60a5 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -11,6 +11,9 @@ #### Bug Fixes +* Static-address loop-in quotes and manual outpoint initiation now reject + deposits that are too close to expiry before contacting the Loop server. + * Loop Out requests now account for channel reserves when checking outbound capacity, preventing swaps from starting when their off-chain payment cannot be funded. diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 497fe3a8e..40bc31101 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1182,6 +1182,27 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context, ) } + params, err := s.staticAddressManager. + GetStaticAddressParameters(ctx) + if err != nil { + return nil, fmt.Errorf("unable to retrieve static "+ + "address parameters: %w", err) + } + + info, err := s.lnd.Client.GetInfo(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get lnd info: %w", + err) + } + + err = validateStaticQuoteDepositsSwappable( + depositList.FilteredDeposits, params.Expiry, + info.BlockHeight, + ) + if err != nil { + return nil, err + } + // If a fractional amount is also selected, we check if it // leads to a dust change output. selectedAmount, err = loopin.DeduceSwapAmount( @@ -2606,6 +2627,29 @@ func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32, return confirmationHeight + int64(expiry) - bestBlockHeight } +// validateStaticQuoteDepositsSwappable rejects manual quote deposits that are +// too close to expiry for the server's static-address loop-in HTLC timeout. +func validateStaticQuoteDepositsSwappable(deposits []*looprpc.Deposit, + csvExpiry uint32, blockHeight uint32) error { + + for _, deposit := range deposits { + if deposit.ConfirmationHeight <= 0 { + continue + } + + confirmationHeight := uint32(deposit.ConfirmationHeight) + swappable := loopin.IsSwappable( + confirmationHeight, blockHeight, csvExpiry, + ) + if !swappable { + return fmt.Errorf("deposit %s expires before htlc", + deposit.Outpoint) + } + } + + return nil +} + // StaticOpenChannel initiates an open channel request using static address // deposits. func (s *swapClientServer) StaticOpenChannel(ctx context.Context, diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index bb4cc01ca..8e8f4e847 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -236,3 +236,33 @@ func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) { }) require.ErrorContains(t, err, "is not currently available") } + +// TestGetLoopInQuoteRejectsExpiringSelectedDeposit verifies manual quote +// requests fail before server quote retrieval when a selected deposit no longer +// has enough timeout runway for a static-address loop-in HTLC. +func TestGetLoopInQuoteRejectsExpiringSelectedDeposit(t *testing.T) { + t.Parallel() + setLogger(btclog.Disabled) + + expiring := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{7}, + Index: 7, + }, + Value: btcutil.Amount(5_000), + ConfirmationHeight: 500, + } + expiring.SetState(deposit.Deposited) + + addrMgr, lnd := newTestStaticAddressContext(t) + server := &swapClientServer{ + depositManager: newTestDepositManager(expiring), + staticAddressManager: addrMgr, + lnd: &lnd.LndServices, + } + + _, err := server.GetLoopInQuote(t.Context(), &looprpc.QuoteRequest{ + DepositOutpoints: []string{expiring.OutPoint.String()}, + }) + require.ErrorContains(t, err, "expires before htlc") +} diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 47a447fc3..b011f33fd 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -658,6 +658,25 @@ func (m *Manager) initiateLoopIn(ctx context.Context, "state %s", deposit.Deposited) } + // The server rejects deposits whose static-address timeout is + // too close to the HTLC timeout. Automatic selection already + // filters those deposits, so manual outpoint selection must + // enforce the same rule before quoting and initiating a swap. + params, err := m.cfg.AddressManager. + GetStaticAddressParameters(ctx) + if err != nil { + return nil, fmt.Errorf("unable to retrieve static "+ + "address parameters: %w", err) + } + + err = ValidateDepositsSwappable( + selectedDeposits, params.Expiry, + m.currentHeight.Load(), + ) + if err != nil { + return nil, err + } + case len(selectedOutpoints) == 0: // If an amount was provided, we'll coin-select deposits to // cover for the amount. @@ -943,6 +962,29 @@ func IsSwappable(confirmationHeight, blockHeight, csvExpiry uint32) bool { ) >= DefaultLoopInOnChainCltvDelta+DepositHtlcDelta } +// ValidateDepositsSwappable verifies that selected deposits still have enough +// timeout runway to back a static-address loop-in HTLC. +func ValidateDepositsSwappable(deposits []*deposit.Deposit, csvExpiry uint32, + blockHeight uint32) error { + + for _, deposit := range deposits { + confirmationHeight := deposit.GetConfirmationHeight() + if confirmationHeight <= 0 { + continue + } + + swappable := IsSwappable( + uint32(confirmationHeight), blockHeight, csvExpiry, + ) + if !swappable { + return fmt.Errorf("deposit %s expires before htlc", + deposit.OutPoint) + } + } + + return nil +} + // blocksUntilDepositExpiry returns the remaining number of blocks until a // deposit expires. Unconfirmed deposits return MaxUint32 because their CSV has // not started yet. diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 9fa8587c2..c9a7800c2 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -223,6 +223,9 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { } manager, err := NewManager(&Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{Expiry: 10_000}, + }, DepositManager: &mockDepositManager{ byOutpoint: map[string]*deposit.Deposit{ selectedOutpoint: selectedDeposit, @@ -245,6 +248,49 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { require.Equal(t, selectedDeposit.Value, quoteGetter.amount) } +// TestInitiateLoopInRejectsExpiringSelectedDeposit verifies manually selected +// outpoints use the same expiry runway check as automatic selection. +func TestInitiateLoopInRejectsExpiringSelectedDeposit(t *testing.T) { + ctx := t.Context() + + const ( + blockHeight = 3_000 + csvExpiry = 1_000 + confirmationHeight = 2_000 + ) + + selectedDeposit := makeDeposit( + 2, 0, 9_000, confirmationHeight, + ) + selectedOutpoint := selectedDeposit.OutPoint.String() + quoteGetter := &mockQuoteGetter{ + err: errors.New("quote should not be reached"), + } + + manager, err := NewManager(&Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{Expiry: csvExpiry}, + }, + DepositManager: &mockDepositManager{ + byOutpoint: map[string]*deposit.Deposit{ + selectedOutpoint: selectedDeposit, + }, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, blockHeight) + require.NoError(t, err) + + _, err = manager.initiateLoopIn(ctx, &loop.StaticAddressLoopInRequest{ + DepositOutpoints: []string{selectedOutpoint}, + SelectedAmount: selectedDeposit.Value, + MaxSwapFee: 1_000, + Initiator: "test", + }) + require.ErrorContains(t, err, "expires before htlc") + require.Zero(t, quoteGetter.amount) +} + // TestUpdateLoopInSendsUpdateAfterSuccessfulStoreUpdate protects the // notification contract: after a successful database update, the manager must // publish the stored loop-in state to listeners.