From f8e9d11d041cda58fdef6a797a05739cb3f2a2ba Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 13:20:45 +0200 Subject: [PATCH] staticaddr/loopin: add lnd txout checker Add a TxOutChecker interface for checking whether a selected deposit outpoint is still available before signing the HTLC transaction. Back the implementation with lnd wallet transaction data so known confirmed and mempool spends mark the outpoint unavailable. --- staticaddr/loopin/interface.go | 10 +++ staticaddr/loopin/txout_checker.go | 79 +++++++++++++++++ staticaddr/loopin/txout_checker_test.go | 112 ++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 staticaddr/loopin/txout_checker.go create mode 100644 staticaddr/loopin/txout_checker_test.go diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index c4bbb2b7..52139020 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -4,6 +4,7 @@ import ( "context" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/deposit" @@ -105,6 +106,15 @@ type QuoteGetter interface { numDeposits uint32, fast bool) (*loop.LoopInQuote, error) } +// TxOutChecker checks whether outpoints are still available in the chain +// backend's UTXO view. +type TxOutChecker interface { + // GetTxOuts returns entries for the requested outpoints that are + // available and unspent. Missing entries are unavailable or spent. + GetTxOuts(ctx context.Context, outpoints []wire.OutPoint) ( + map[wire.OutPoint]*wire.TxOut, error) +} + type NotificationManager interface { // SubscribeStaticLoopInSweepRequests subscribes to the static loop in // sweep requests. These are sent by the server to the client to request diff --git a/staticaddr/loopin/txout_checker.go b/staticaddr/loopin/txout_checker.go new file mode 100644 index 00000000..da14be7c --- /dev/null +++ b/staticaddr/loopin/txout_checker.go @@ -0,0 +1,79 @@ +package loopin + +import ( + "context" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" +) + +// lndTxOutChecker checks outpoint availability using lnd's wallet transaction +// view. It omits outputs already spent by a wallet-known transaction. +type lndTxOutChecker struct { + client lndclient.LightningClient +} + +// NewLndTxOutChecker creates a TxOutChecker backed by lnd. +func NewLndTxOutChecker(client lndclient.LightningClient) TxOutChecker { + return &lndTxOutChecker{ + client: client, + } +} + +// GetTxOuts returns all requested tx outputs that lnd's transaction view still +// reports as unspent. +func (c *lndTxOutChecker) GetTxOuts(ctx context.Context, + outpoints []wire.OutPoint) (map[wire.OutPoint]*wire.TxOut, error) { + + outpointByString := make(map[string]wire.OutPoint, len(outpoints)) + outpointsByHash := make(map[string][]wire.OutPoint, len(outpoints)) + for _, outpoint := range outpoints { + outpointByString[outpoint.String()] = outpoint + outpointsByHash[outpoint.Hash.String()] = append( + outpointsByHash[outpoint.Hash.String()], outpoint, + ) + } + + // We need lnd's wallet transaction view rather than only the funding + // transaction: a matching previous outpoint tells us the deposit has + // already been spent by a wallet-known transaction. Use endHeight=-1 so + // lnd includes unconfirmed transactions and mempool spends. + txs, err := c.client.ListTransactions(ctx, 0, -1) + if err != nil { + return nil, err + } + + txOuts := make(map[wire.OutPoint]*wire.TxOut, len(outpoints)) + spent := make(map[wire.OutPoint]struct{}, len(outpoints)) + for _, tx := range txs { + for _, prevOutpoint := range tx.PreviousOutpoints { + outpoint, ok := outpointByString[prevOutpoint.GetOutpoint()] + if ok { + spent[outpoint] = struct{}{} + } + } + + if tx.Tx == nil { + continue + } + + txHash := tx.TxHash + if txHash == "" { + txHash = tx.Tx.TxHash().String() + } + + for _, outpoint := range outpointsByHash[txHash] { + if int(outpoint.Index) >= len(tx.Tx.TxOut) { + continue + } + + txOuts[outpoint] = tx.Tx.TxOut[outpoint.Index] + } + } + + for outpoint := range spent { + delete(txOuts, outpoint) + } + + return txOuts, nil +} diff --git a/staticaddr/loopin/txout_checker_test.go b/staticaddr/loopin/txout_checker_test.go new file mode 100644 index 00000000..491d9ce9 --- /dev/null +++ b/staticaddr/loopin/txout_checker_test.go @@ -0,0 +1,112 @@ +package loopin + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/require" +) + +func TestLndTxOutChecker(t *testing.T) { + fundingTx := wire.NewMsgTx(2) + fundingTx.AddTxOut(wire.NewTxOut(1000, []byte{0x01})) + fundingTx.AddTxOut(wire.NewTxOut(2000, []byte{0x02})) + + outpoint := wire.OutPoint{ + Hash: fundingTx.TxHash(), + Index: 1, + } + + t.Run("returns live tx outputs", func(t *testing.T) { + otherOutpoint := wire.OutPoint{ + Hash: fundingTx.TxHash(), + Index: 0, + } + client := &mockTxListLightningClient{ + txs: []lndclient.Transaction{{ + Tx: fundingTx, + }}, + } + + checker := NewLndTxOutChecker(client) + txOuts, err := checker.GetTxOuts( + t.Context(), []wire.OutPoint{outpoint, otherOutpoint}, + ) + require.NoError(t, err) + require.Equal(t, fundingTx.TxOut[outpoint.Index], txOuts[outpoint]) + require.Equal( + t, fundingTx.TxOut[otherOutpoint.Index], + txOuts[otherOutpoint], + ) + require.Equal(t, []txListCall{{ + startHeight: 0, + endHeight: -1, + }}, client.calls) + }) + + t.Run("returns nil for known spend", func(t *testing.T) { + client := &mockTxListLightningClient{ + txs: []lndclient.Transaction{{ + Tx: fundingTx, + }, { + PreviousOutpoints: []*lnrpc.PreviousOutPoint{{ + Outpoint: outpoint.String(), + }}, + }}, + } + + checker := NewLndTxOutChecker(client) + txOuts, err := checker.GetTxOuts( + t.Context(), []wire.OutPoint{outpoint}, + ) + require.NoError(t, err) + require.Nil(t, txOuts[outpoint]) + require.Equal(t, []txListCall{{ + startHeight: 0, + endHeight: -1, + }}, client.calls) + }) + + t.Run("returns error", func(t *testing.T) { + expectedErr := errors.New("list transactions failed") + client := &mockTxListLightningClient{ + err: expectedErr, + } + + checker := NewLndTxOutChecker(client) + txOuts, err := checker.GetTxOuts( + t.Context(), []wire.OutPoint{outpoint}, + ) + require.ErrorIs(t, err, expectedErr) + require.Nil(t, txOuts) + }) +} + +type txListCall struct { + startHeight int32 + endHeight int32 +} + +type mockTxListLightningClient struct { + lndclient.LightningClient + + txs []lndclient.Transaction + err error + calls []txListCall +} + +func (m *mockTxListLightningClient) ListTransactions(_ context.Context, + startHeight, endHeight int32, _ ...lndclient.ListTransactionsOption) ( + []lndclient.Transaction, error) { + + m.calls = append(m.calls, txListCall{ + startHeight: startHeight, + endHeight: endHeight, + }) + + return m.txs, m.err +}