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.
This commit is contained in:
Slyghtning 2026-06-26 13:20:45 +02:00
parent 508bf90a1c
commit f8e9d11d04
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
3 changed files with 201 additions and 0 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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
}