staticaddr/loopin: add lnd txout checker

Add a TxOutChecker interface for testing whether an original deposit
outpoint is still available before a static loop-in signs its HTLC
transaction.

Implement the checker using lnd's wallet transaction view so wallet-known
spends, including mempool spends when requested, cause the outpoint to be
reported unavailable.
This commit is contained in:
Slyghtning 2026-06-26 13:20:45 +02:00
parent cee30f29ae
commit e02e5bc258
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
3 changed files with 181 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"
@ -108,6 +109,16 @@ type QuoteGetter interface {
numDeposits uint32, fast bool) (*loop.LoopInQuote, error)
}
// TxOutChecker checks whether an outpoint is still available in the chain
// backend's UTXO view.
type TxOutChecker interface {
// GetTxOut returns nil if the outpoint is unavailable or spent. The
// includeMempool flag must be passed through to the underlying chain
// backend.
GetTxOut(ctx context.Context, outpoint wire.OutPoint,
includeMempool bool) (*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,72 @@
package loopin
import (
"context"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
)
// lndTxOutChecker checks outpoint availability using lnd's wallet transaction
// view. It returns nil for 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,
}
}
// GetTxOut returns the tx output if lnd's transaction view still reports the
// outpoint as unspent.
func (c *lndTxOutChecker) GetTxOut(ctx context.Context,
outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) {
endHeight := int32(0)
if includeMempool {
endHeight = -1
}
// 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. When mempool spends
// matter, lnd exposes them through ListTransactions with endHeight=-1.
txs, err := c.client.ListTransactions(ctx, 0, endHeight)
if err != nil {
return nil, err
}
outpointStr := outpoint.String()
for _, tx := range txs {
for _, prevOutpoint := range tx.PreviousOutpoints {
if prevOutpoint.GetOutpoint() == outpointStr {
return nil, nil
}
}
}
for _, tx := range txs {
if tx.Tx == nil {
continue
}
txHash := tx.TxHash
if txHash == "" {
txHash = tx.Tx.TxHash().String()
}
if txHash != outpoint.Hash.String() {
continue
}
if int(outpoint.Index) >= len(tx.Tx.TxOut) {
return nil, nil
}
return tx.Tx.TxOut[outpoint.Index], nil
}
return nil, nil
}

View file

@ -0,0 +1,98 @@
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 output", func(t *testing.T) {
client := &mockTxListLightningClient{
txs: []lndclient.Transaction{{
Tx: fundingTx,
}},
}
checker := NewLndTxOutChecker(client)
txOut, err := checker.GetTxOut(t.Context(), outpoint, false)
require.NoError(t, err)
require.Equal(t, fundingTx.TxOut[outpoint.Index], txOut)
require.Equal(t, []txListCall{{
startHeight: 0,
endHeight: 0,
}}, 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)
txOut, err := checker.GetTxOut(t.Context(), outpoint, true)
require.NoError(t, err)
require.Nil(t, txOut)
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)
txOut, err := checker.GetTxOut(t.Context(), outpoint, false)
require.ErrorIs(t, err, expectedErr)
require.Nil(t, txOut)
})
}
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
}