staticaddr/loopin: cancel signing for unavailable deposits

Check the originally selected deposit outpoints before signing a static
loop-in HTLC transaction. If any selected outpoint is no longer
available, cancel the swap invoice and fail the signing action instead
of producing signatures for stale inputs.
This commit is contained in:
Slyghtning 2026-07-08 13:55:50 +02:00
parent 1d935c657f
commit ef78c85e88
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
4 changed files with 195 additions and 6 deletions

View file

@ -344,7 +344,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
// cancelSwapInvoice best-effort cancels the current swap invoice using a
// detached timeout-limited context.
func (f *FSM) cancelSwapInvoice() {
if f.loopIn.SwapInvoice == "" {
if f.loopIn.SwapHash == (lntypes.Hash{}) {
return
}
@ -389,6 +389,44 @@ func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
}
}
// originalDepositOutpointUnavailable checks the original selected deposit
// outpoints against the chain backend's UTXO view.
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
bool, error) {
if f.cfg.TxOutChecker == nil {
return false, nil
}
if len(f.loopIn.DepositOutpoints) == 0 {
return false, nil
}
outpoints := make([]wire.OutPoint, len(f.loopIn.DepositOutpoints))
for i, outpointStr := range f.loopIn.DepositOutpoints {
outpoint, err := wire.NewOutPointFromString(outpointStr)
if err != nil {
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
outpointStr, err)
}
outpoints[i] = *outpoint
}
txOuts, err := f.cfg.TxOutChecker.GetTxOuts(ctx, outpoints)
if err != nil {
return false, fmt.Errorf("unable to get txouts: %w", err)
}
for _, outpoint := range outpoints {
if txOuts[outpoint] == nil {
return true, nil
}
}
return false, nil
}
// SignHtlcTxAction is called if the htlc was initialized and the server
// provided the necessary information to construct the htlc tx. We sign the htlc
// tx and send the signatures to the server.
@ -397,6 +435,18 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
var err error
outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx)
if err != nil {
return f.HandleError(err)
}
if outpointUnavailable {
err = errors.New("original deposit outpoint no longer available")
f.Warnf("%v, canceling swap invoice", err)
f.cancelSwapInvoice()
return f.HandleError(err)
}
f.loopIn.AddressParams, err =
f.cfg.AddressManager.GetStaticAddressParameters(ctx)

View file

@ -133,10 +133,10 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) {
loopIn.SetState(MonitorInvoiceAndHtlcTx)
// Seed the mock invoice store so LookupInvoice succeeds.
mockLnd.Invoices[swapHash] = &lndclient.Invoice{
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
}
})
cfg := &Config{
AddressManager: &mockAddressManager{
@ -452,7 +452,6 @@ func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) {
},
loopIn: &StaticAddressLoopIn{
Deposits: []*deposit.Deposit{dep},
DepositOutpoints: []string{dep.OutPoint.String()},
},
}
@ -575,6 +574,135 @@ func testValidateLoopInContract(_ int32, _ int32) error {
return nil
}
// TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a
// present txout does not trigger the RBF cancellation path.
func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) {
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 0,
}
txOutChecker := &recordingTxOutChecker{
txOuts: map[wire.OutPoint]*wire.TxOut{
originalOutpoint: {Value: 10_000},
},
}
f := &FSM{
cfg: &Config{
TxOutChecker: txOutChecker,
},
loopIn: &StaticAddressLoopIn{
DepositOutpoints: []string{originalOutpoint.String()},
},
}
unavailable, err := f.originalDepositOutpointUnavailable(t.Context())
require.NoError(t, err)
require.False(t, unavailable)
require.Equal(t, [][]wire.OutPoint{{originalOutpoint}},
txOutChecker.outpoints)
}
// TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a
// pending loop-in is canceled before HTLC signing if GetTxOuts reports that
// one of the originally selected outpoints is gone.
func TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{9, 8, 7}
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
DepositOutpoints: []string{originalOutpoint.String()},
}
txOutChecker := &recordingTxOutChecker{}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
InvoicesClient: mockLnd.LndServices.Invoices,
TxOutChecker: txOutChecker,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
event := f.SignHtlcTxAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError, "original deposit outpoint no longer available",
)
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
require.Equal(t, [][]wire.OutPoint{{originalOutpoint}},
txOutChecker.outpoints)
}
// TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup
// failures are treated as errors, but do not cancel the invoice. The invoice is
// only canceled when GetTxOuts omits an original outpoint.
func TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{9, 8, 6}
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
DepositOutpoints: []string{originalOutpoint.String()},
}
txOutChecker := &recordingTxOutChecker{
err: errors.New("backend unavailable"),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
InvoicesClient: mockLnd.LndServices.Invoices,
TxOutChecker: txOutChecker,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
event := f.SignHtlcTxAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError, "unable to get txout",
)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice should not have been canceled: %x", hash)
default:
}
}
// TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice
// created before a server-side rejection is canceled immediately.
func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) {

View file

@ -155,7 +155,9 @@ func (h *mockLightningClient) LookupInvoice(_ context.Context,
return nil, fmt.Errorf("invoice: %x not found", hash)
}
return inv, nil
invoiceCopy := *inv
return &invoiceCopy, nil
}
// ListTransactions returns all known transactions of the backing lnd node.

View file

@ -225,6 +225,15 @@ func (s *LndMockServices) AddTx(tx *wire.MsgTx) {
s.lock.Unlock()
}
// SetInvoice stores a copy of the given invoice in the mock invoice store.
func (s *LndMockServices) SetInvoice(invoice *lndclient.Invoice) {
s.lock.Lock()
defer s.lock.Unlock()
invoiceCopy := *invoice
s.Invoices[invoice.Hash] = &invoiceCopy
}
// IsDone checks whether all channels have been fully emptied. If not this may
// indicate unexpected behaviour of the code under test.
func (s *LndMockServices) IsDone() error {