mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
432 lines
11 KiB
Go
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())
|
|
}
|