loop/loopd/htlc_confirmed_recovery_test.go
2026-05-29 23:00:29 -05:00

432 lines
11 KiB
Go

package loopd
import (
"bytes"
"context"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightninglabs/loop/sweep"
"github.com/lightninglabs/loop/test"
"github.com/lightninglabs/loop/utils"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
)
const (
// htlcConfirmedRecoveryTestTimeout bounds recovery worker tests so
// channel interactions cannot block the suite indefinitely.
htlcConfirmedRecoveryTestTimeout = 5 * time.Second
// htlcConfirmedRecoveryNoEventWait bounds negative channel assertions.
htlcConfirmedRecoveryNoEventWait = 100 * time.Millisecond
)
// mockHtlcConfirmedSubscriber exposes a controllable HTLC-confirmed
// notification stream for recovery worker tests.
type mockHtlcConfirmedSubscriber struct {
ntfnChan chan *swapserverrpc.ServerHtlcConfirmedNotification
}
// newMockHtlcConfirmedSubscriber creates a buffered notification source for
// the recovery worker tests.
func newMockHtlcConfirmedSubscriber() *mockHtlcConfirmedSubscriber {
return &mockHtlcConfirmedSubscriber{
ntfnChan: make(
chan *swapserverrpc.ServerHtlcConfirmedNotification, 2,
),
}
}
// SubscribeHtlcConfirmed returns the test-controlled notification stream.
func (m *mockHtlcConfirmedSubscriber) SubscribeHtlcConfirmed(
context.Context) <-chan *swapserverrpc.ServerHtlcConfirmedNotification {
return m.ntfnChan
}
// htlcConfirmedRecoveryFixture bundles the mocks and swap data used by the
// recovery worker tests.
type htlcConfirmedRecoveryFixture struct {
lnd *test.LndMockServices
store *loopdb.StoreMock
subscriber *mockHtlcConfirmedSubscriber
manager *htlcConfirmedRecoveryManager
swap *loopdb.LoopOut
htlc *swap.Htlc
fundingTx *wire.MsgTx
outpoint wire.OutPoint
destPkScript []byte
}
// newHtlcConfirmedRecoveryFixture builds a loop-out swap and the mock daemon
// services needed to recover its HTLC.
func newHtlcConfirmedRecoveryFixture(
t *testing.T) *htlcConfirmedRecoveryFixture {
t.Helper()
lnd := test.NewMockLnd()
store := loopdb.NewStoreMock(t)
preimage := lntypes.Preimage{1, 2, 3, 4}
swapHash := preimage.Hash()
// Construct deterministic HTLC keys so the test reconstructs a stable
// swap script and outpoint.
_, senderPub := test.CreateKey(0)
_, receiverPub := test.CreateKey(1)
var senderKey, receiverKey [33]byte
copy(senderKey[:], senderPub.SerializeCompressed())
copy(receiverKey[:], receiverPub.SerializeCompressed())
htlcKeys := loopdb.HtlcKeys{
SenderScriptKey: senderKey,
ReceiverScriptKey: receiverKey,
ClientScriptKeyLocator: keychain.KeyLocator{
Family: keychain.KeyFamily(swap.KeyFamily),
Index: 7,
},
}
destAddr, err := btcutil.NewAddressWitnessPubKeyHash(
bytes.Repeat([]byte{2}, 20), lnd.ChainParams,
)
require.NoError(t, err)
swapContract := &loopdb.LoopOutContract{
SwapContract: loopdb.SwapContract{
Preimage: preimage,
AmountRequested: 1_000_000,
HtlcKeys: htlcKeys,
CltvExpiry: 500,
InitiationHeight: 123,
ProtocolVersion: loopdb.ProtocolVersionHtlcV2,
},
DestAddr: destAddr,
}
loopOut := &loopdb.LoopOut{
Loop: loopdb.Loop{
Hash: swapHash,
},
Contract: swapContract,
}
store.LoopOutSwaps[swapHash] = swapContract
// Fund the reconstructed HTLC with a single output so the worker has a
// recoverable success-path spend target.
htlc, err := utils.GetHtlc(
swapHash, &swapContract.SwapContract, lnd.ChainParams,
)
require.NoError(t, err)
fundingTx := wire.NewMsgTx(2)
fundingTx.AddTxOut(&wire.TxOut{
Value: int64(swapContract.AmountRequested),
PkScript: htlc.PkScript,
})
outpoint := wire.OutPoint{
Hash: fundingTx.TxHash(),
Index: 0,
}
destPkScript, err := txscript.PayToAddrScript(destAddr)
require.NoError(t, err)
subscriber := newMockHtlcConfirmedSubscriber()
manager := &htlcConfirmedRecoveryManager{
notificationSource: subscriber,
swapStore: store,
chainParams: lnd.ChainParams,
notifier: lnd.ChainNotifier,
wallet: lnd.WalletKit,
signer: lnd.Signer,
}
return &htlcConfirmedRecoveryFixture{
lnd: lnd,
store: store,
subscriber: subscriber,
manager: manager,
swap: loopOut,
htlc: htlc,
fundingTx: fundingTx,
outpoint: outpoint,
destPkScript: destPkScript,
}
}
// waitForConfRegistration waits for the recovery worker to register a single
// confirmation notification.
func waitForConfRegistration(t *testing.T, ctx context.Context,
lnd *test.LndMockServices) *test.ConfRegistration {
t.Helper()
select {
case reg := <-lnd.RegisterConfChannel:
return reg
case <-ctx.Done():
t.Fatalf("timed out waiting for confirmation registration: %v",
ctx.Err())
return nil
}
}
// waitForSignOutputRawRequest waits for the worker to request a signature.
func waitForSignOutputRawRequest(t *testing.T, ctx context.Context,
lnd *test.LndMockServices) test.SignOutputRawRequest {
t.Helper()
select {
case req := <-lnd.SignOutputRawChannel:
return req
case <-ctx.Done():
t.Fatalf("timed out waiting for sign request: %v", ctx.Err())
return test.SignOutputRawRequest{}
}
}
// waitForPublishedTx waits for the recovery worker to publish its sweep.
func waitForPublishedTx(t *testing.T, ctx context.Context,
lnd *test.LndMockServices) *wire.MsgTx {
t.Helper()
select {
case tx := <-lnd.TxPublishChannel:
return tx
case <-ctx.Done():
t.Fatalf("timed out waiting for published tx: %v", ctx.Err())
return nil
}
}
// waitForManagerExit waits for the recovery worker goroutine to stop.
func waitForManagerExit(t *testing.T, ctx context.Context,
runErrChan <-chan error) error {
t.Helper()
select {
case err := <-runErrChan:
return err
case <-ctx.Done():
t.Fatalf("timed out waiting for recovery worker exit: %v",
ctx.Err())
return nil
}
}
// assertNoRecoveryActivity verifies that the recovery worker did not start a
// sweep attempt.
func assertNoRecoveryActivity(t *testing.T, lnd *test.LndMockServices) {
t.Helper()
select {
case reg := <-lnd.RegisterConfChannel:
t.Fatalf("unexpected confirmation registration: %+v", reg)
case <-time.After(htlcConfirmedRecoveryNoEventWait):
}
select {
case req := <-lnd.SignOutputRawChannel:
t.Fatalf("unexpected sign request: %+v", req)
case <-time.After(htlcConfirmedRecoveryNoEventWait):
}
select {
case tx := <-lnd.TxPublishChannel:
t.Fatalf("unexpected published tx: %v", tx.TxHash())
case <-time.After(htlcConfirmedRecoveryNoEventWait):
}
}
// TestHtlcConfirmedRecoveryManagerPublishesSweep verifies that a valid
// notification triggers a direct sweep to the stored destination address.
func TestHtlcConfirmedRecoveryManagerPublishesSweep(t *testing.T) {
defer test.Guard(t)()
setLogger(newFormatLogger())
fixture := newHtlcConfirmedRecoveryFixture(t)
ctx, cancel := context.WithTimeout(
t.Context(), htlcConfirmedRecoveryTestTimeout,
)
defer cancel()
runErrChan := make(chan error, 1)
go func() {
runErrChan <- fixture.manager.run(ctx)
}()
ntfn := &swapserverrpc.ServerHtlcConfirmedNotification{
SwapHash: fixture.swap.Hash[:],
HtlcOutpoint: fixture.outpoint.String(),
HtlcAddress: fixture.htlc.Address.String(),
SatPerVbyte: 25,
}
fixture.subscriber.ntfnChan <- ntfn
close(fixture.subscriber.ntfnChan)
reg := waitForConfRegistration(t, ctx, fixture.lnd)
require.NotNil(t, reg.TxID)
require.Equal(t, fixture.outpoint.Hash, *reg.TxID)
require.Equal(t, fixture.htlc.PkScript, reg.PkScript)
fixture.lnd.ConfChannel <- &chainntnfs.TxConfirmation{
Tx: fixture.fundingTx,
BlockHeight: 321,
}
signReq := waitForSignOutputRawRequest(t, ctx, fixture.lnd)
require.Equal(
t, fixture.outpoint, signReq.Tx.TxIn[0].PreviousOutPoint,
)
require.Len(t, signReq.Tx.TxOut, 1)
require.Equal(
t, fixture.destPkScript, signReq.Tx.TxOut[0].PkScript,
)
publishedTx := waitForPublishedTx(t, ctx, fixture.lnd)
require.Equal(
t, fixture.outpoint, publishedTx.TxIn[0].PreviousOutPoint,
)
require.Len(t, publishedTx.TxOut, 1)
require.Equal(
t, fixture.destPkScript, publishedTx.TxOut[0].PkScript,
)
require.NotEmpty(t, publishedTx.TxIn[0].Witness)
require.Equal(
t, expectedHtlcConfirmedRecoveryFee(t, fixture, 25),
htlcConfirmedRecoveryFee(
fixture.swap.Contract.AmountRequested, publishedTx,
),
)
require.NoError(t, waitForManagerExit(t, ctx, runErrChan))
require.NoError(t, fixture.lnd.IsDone())
}
// TestHtlcConfirmedRecoveryManagerIgnoresZeroFeeRate verifies that a zero-fee
// notification is rejected by the shared sweep path without publishing a tx.
func TestHtlcConfirmedRecoveryManagerIgnoresZeroFeeRate(t *testing.T) {
defer test.Guard(t)()
setLogger(newFormatLogger())
fixture := newHtlcConfirmedRecoveryFixture(t)
ctx, cancel := context.WithTimeout(
t.Context(), htlcConfirmedRecoveryTestTimeout,
)
defer cancel()
runErrChan := make(chan error, 1)
go func() {
runErrChan <- fixture.manager.run(ctx)
}()
ntfn := &swapserverrpc.ServerHtlcConfirmedNotification{
SwapHash: fixture.swap.Hash[:],
HtlcOutpoint: fixture.outpoint.String(),
HtlcAddress: fixture.htlc.Address.String(),
}
fixture.subscriber.ntfnChan <- ntfn
close(fixture.subscriber.ntfnChan)
assertNoRecoveryActivity(t, fixture.lnd)
require.NoError(t, waitForManagerExit(t, ctx, runErrChan))
require.NoError(t, fixture.lnd.IsDone())
}
// TestHtlcConfirmedRecoveryManagerIgnoresBadNotification verifies that a bad
// notification is ignored without starting a sweep attempt.
func TestHtlcConfirmedRecoveryManagerIgnoresBadNotification(t *testing.T) {
defer test.Guard(t)()
setLogger(newFormatLogger())
fixture := newHtlcConfirmedRecoveryFixture(t)
ctx, cancel := context.WithTimeout(
t.Context(), htlcConfirmedRecoveryTestTimeout,
)
defer cancel()
runErrChan := make(chan error, 1)
go func() {
runErrChan <- fixture.manager.run(ctx)
}()
ntfn := &swapserverrpc.ServerHtlcConfirmedNotification{
SwapHash: fixture.swap.Hash[:],
HtlcOutpoint: "not-an-outpoint",
HtlcAddress: fixture.htlc.Address.String(),
}
fixture.subscriber.ntfnChan <- ntfn
close(fixture.subscriber.ntfnChan)
require.NoError(t, waitForManagerExit(t, ctx, runErrChan))
assertNoRecoveryActivity(t, fixture.lnd)
require.NoError(t, fixture.lnd.IsDone())
}
// htlcConfirmedRecoveryFee returns the fee paid by a single-input recovery
// sweep transaction.
func htlcConfirmedRecoveryFee(inputValue btcutil.Amount,
tx *wire.MsgTx) btcutil.Amount {
outputValue := btcutil.Amount(tx.TxOut[0].Value)
return inputValue - outputValue
}
// expectedHtlcConfirmedRecoveryFee computes the fee using the same estimator
// inputs as sweepHtlc.
func expectedHtlcConfirmedRecoveryFee(t *testing.T,
fixture *htlcConfirmedRecoveryFixture, satPerVByte uint32) btcutil.Amount {
t.Helper()
var estimator input.TxWeightEstimator
err := fixture.htlc.AddSuccessToEstimator(&estimator)
require.NoError(t, err)
err = sweep.AddOutputEstimate(&estimator, fixture.swap.Contract.DestAddr)
require.NoError(t, err)
feeRate := chainfee.SatPerVByte(satPerVByte).FeePerKWeight()
return feeRate.FeeForWeightRoundUp(estimator.Weight())
}