lnd/sweep/fee_bumper_test.go
Olaoluwa Osuntokun cb93f8c01a
sweep: add new AuxSweeper interface
In this commit, we add a new AuxSweeper interface. This'll take a set of
inputs, and a change addr for the sweep transaction, then optionally
return a new sweep output to be added to the sweep transaction.

We also add a new NotifyBroadcast method.  This'll be used to notify
that we're _about_ to broadcast a sweeping transaction. The set of
inputs is passed in, which allows the caller to prepare for the ultimate
broadcast of the sweeping transaction.

We also add ExtraTxOut to BumpRequest pass fees to NotifyBroadcast. This
allows the callee to know the total fee of the sweeping transaction.
2024-10-02 18:09:57 -07:00

1476 lines
40 KiB
Go

package sweep
import (
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/chain"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
var (
// Create a taproot change script.
changePkScript = lnwallet.AddrWithKey{
DeliveryAddress: []byte{
0x51, 0x20,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
},
}
testInputCount atomic.Uint64
)
func createTestInput(value int64,
witnessType input.WitnessType) input.BaseInput {
hash := chainhash.Hash{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
byte(testInputCount.Add(1))}
input := input.MakeBaseInput(
&wire.OutPoint{
Hash: hash,
},
witnessType,
&input.SignDescriptor{
Output: &wire.TxOut{
Value: value,
},
KeyDesc: keychain.KeyDescriptor{
PubKey: testPubKey,
},
},
0,
nil,
)
return input
}
// TestBumpResultValidate tests the validate method of the BumpResult struct.
func TestBumpResultValidate(t *testing.T) {
t.Parallel()
// An empty result will give an error.
b := BumpResult{}
require.ErrorIs(t, b.Validate(), ErrInvalidBumpResult)
// Unknown event type will give an error.
b = BumpResult{
Tx: &wire.MsgTx{},
Event: sentinalEvent,
}
require.ErrorIs(t, b.Validate(), ErrInvalidBumpResult)
// A replacing event without a new tx will give an error.
b = BumpResult{
Tx: &wire.MsgTx{},
Event: TxReplaced,
}
require.ErrorIs(t, b.Validate(), ErrInvalidBumpResult)
// A failed event without a failure reason will give an error.
b = BumpResult{
Tx: &wire.MsgTx{},
Event: TxFailed,
}
require.ErrorIs(t, b.Validate(), ErrInvalidBumpResult)
// A confirmed event without fee info will give an error.
b = BumpResult{
Tx: &wire.MsgTx{},
Event: TxConfirmed,
}
require.ErrorIs(t, b.Validate(), ErrInvalidBumpResult)
// Test a valid result.
b = BumpResult{
Tx: &wire.MsgTx{},
Event: TxPublished,
}
require.NoError(t, b.Validate())
}
// TestCalcSweepTxWeight checks that the weight of the sweep tx is calculated
// correctly.
func TestCalcSweepTxWeight(t *testing.T) {
t.Parallel()
// Create an input.
inp := createTestInput(100, input.WitnessKeyHash)
// Use a wrong change script to test the error case.
weight, err := calcSweepTxWeight([]input.Input{&inp}, []byte{0})
require.Error(t, err)
require.Zero(t, weight)
// Use a correct change script to test the success case.
weight, err = calcSweepTxWeight(
[]input.Input{&inp}, changePkScript.DeliveryAddress,
)
require.NoError(t, err)
// BaseTxSize 8 bytes
// InputSize 1+41 bytes
// One P2TROutputSize 1+43 bytes
// One P2WKHWitnessSize 2+109 bytes
// Total weight = (8+42+44) * 4 + 111 = 487
require.EqualValuesf(t, 487, weight, "unexpected weight %v", weight)
}
// TestBumpRequestMaxFeeRateAllowed tests the max fee rate allowed for a bump
// request.
func TestBumpRequestMaxFeeRateAllowed(t *testing.T) {
t.Parallel()
// Create a test input.
inp := createTestInput(100, input.WitnessKeyHash)
// The weight is 487.
weight, err := calcSweepTxWeight(
[]input.Input{&inp}, changePkScript.DeliveryAddress,
)
require.NoError(t, err)
// Define a test budget and calculates its fee rate.
budget := btcutil.Amount(1000)
budgetFeeRate := chainfee.NewSatPerKWeight(budget, weight)
testCases := []struct {
name string
req *BumpRequest
expectedMaxFeeRate chainfee.SatPerKWeight
expectedErr bool
}{
{
// Use a wrong change script to test the error case.
name: "error calc weight",
req: &BumpRequest{
DeliveryAddress: lnwallet.AddrWithKey{
DeliveryAddress: []byte{1},
},
},
expectedMaxFeeRate: 0,
expectedErr: true,
},
{
// When the budget cannot give a fee rate that matches
// the supplied MaxFeeRate, the max allowed feerate is
// capped by the budget.
name: "use budget as max fee rate",
req: &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: budget,
MaxFeeRate: budgetFeeRate + 1,
},
expectedMaxFeeRate: budgetFeeRate,
},
{
// When the budget can give a fee rate that matches the
// supplied MaxFeeRate, the max allowed feerate is
// capped by the MaxFeeRate.
name: "use config as max fee rate",
req: &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: budget,
MaxFeeRate: budgetFeeRate - 1,
},
expectedMaxFeeRate: budgetFeeRate - 1,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Check the method under test.
maxFeeRate, err := tc.req.MaxFeeRateAllowed()
// If we expect an error, check the error is returned
// and the feerate is empty.
if tc.expectedErr {
require.Error(t, err)
require.Zero(t, maxFeeRate)
return
}
// Otherwise, check the max fee rate is as expected.
require.NoError(t, err)
require.Equal(t, tc.expectedMaxFeeRate, maxFeeRate)
})
}
}
// TestCalcCurrentConfTarget checks that the current confirmation target is
// calculated correctly.
func TestCalcCurrentConfTarget(t *testing.T) {
t.Parallel()
// When the current block height is 100 and deadline height is 200, the
// conf target should be 100.
conf := calcCurrentConfTarget(int32(100), int32(200))
require.EqualValues(t, 100, conf)
// When the current block height is 200 and deadline height is 100, the
// conf target should be 0 since the deadline has passed.
conf = calcCurrentConfTarget(int32(200), int32(100))
require.EqualValues(t, 0, conf)
}
// TestInitializeFeeFunction tests the initialization of the fee function.
func TestInitializeFeeFunction(t *testing.T) {
t.Parallel()
// Create a test input.
inp := createTestInput(100, input.WitnessKeyHash)
// Create a mock fee estimator.
estimator := &chainfee.MockEstimator{}
defer estimator.AssertExpectations(t)
// Create a publisher using the mocks.
tp := NewTxPublisher(TxPublisherConfig{
Estimator: estimator,
AuxSweeper: fn.Some[AuxSweeper](&MockAuxSweeper{}),
})
// Create a test feerate.
feerate := chainfee.SatPerKWeight(1000)
// Create a testing bump request.
req := &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: btcutil.Amount(1000),
MaxFeeRate: feerate * 10,
DeadlineHeight: 10,
}
// Mock the fee estimator to return an error.
//
// We are not testing `NewLinearFeeFunction` here, so the actual params
// used are irrelevant.
dummyErr := fmt.Errorf("dummy error")
estimator.On("EstimateFeePerKW", mock.Anything).Return(
chainfee.SatPerKWeight(0), dummyErr).Once()
// Call the method under test and assert the error is returned.
f, err := tp.initializeFeeFunction(req)
require.ErrorIs(t, err, dummyErr)
require.Nil(t, f)
// Mock the fee estimator to return the testing fee rate.
//
// We are not testing `NewLinearFeeFunction` here, so the actual params
// used are irrelevant.
estimator.On("EstimateFeePerKW", mock.Anything).Return(
feerate, nil).Once()
estimator.On("RelayFeePerKW").Return(chainfee.FeePerKwFloor).Once()
// Call the method under test.
f, err = tp.initializeFeeFunction(req)
require.NoError(t, err)
require.Equal(t, feerate, f.FeeRate())
}
// TestStoreRecord correctly increases the request counter and saves the
// record.
func TestStoreRecord(t *testing.T) {
t.Parallel()
// Create a test input.
inp := createTestInput(1000, input.WitnessKeyHash)
// Create a bump request.
req := &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: btcutil.Amount(1000),
}
// Create a naive fee function.
feeFunc := &LinearFeeFunction{}
// Create a test fee and tx.
fee := btcutil.Amount(1000)
tx := &wire.MsgTx{}
// Create a publisher using the mocks.
tp := NewTxPublisher(TxPublisherConfig{
AuxSweeper: fn.Some[AuxSweeper](&MockAuxSweeper{}),
})
// Get the current counter and check it's increased later.
initialCounter := tp.requestCounter.Load()
// Call the method under test.
requestID := tp.storeRecord(tx, req, feeFunc, fee)
// Check the request ID is as expected.
require.Equal(t, initialCounter+1, requestID)
// Read the saved record and compare.
record, ok := tp.records.Load(requestID)
require.True(t, ok)
require.Equal(t, tx, record.tx)
require.Equal(t, feeFunc, record.feeFunction)
require.Equal(t, fee, record.fee)
require.Equal(t, req, record.req)
}
// mockers wraps a list of mocked interfaces used inside tx publisher.
type mockers struct {
signer *input.MockInputSigner
wallet *MockWallet
estimator *chainfee.MockEstimator
notifier *chainntnfs.MockChainNotifier
feeFunc *MockFeeFunction
}
// createTestPublisher creates a new tx publisher using the provided mockers.
func createTestPublisher(t *testing.T) (*TxPublisher, *mockers) {
// Create a mock fee estimator.
estimator := &chainfee.MockEstimator{}
// Create a mock fee function.
feeFunc := &MockFeeFunction{}
// Create a mock signer.
signer := &input.MockInputSigner{}
// Create a mock wallet.
wallet := &MockWallet{}
// Create a mock chain notifier.
notifier := &chainntnfs.MockChainNotifier{}
t.Cleanup(func() {
estimator.AssertExpectations(t)
feeFunc.AssertExpectations(t)
signer.AssertExpectations(t)
wallet.AssertExpectations(t)
notifier.AssertExpectations(t)
})
m := &mockers{
signer: signer,
wallet: wallet,
estimator: estimator,
notifier: notifier,
feeFunc: feeFunc,
}
// Create a publisher using the mocks.
tp := NewTxPublisher(TxPublisherConfig{
Estimator: m.estimator,
Signer: m.signer,
Wallet: m.wallet,
Notifier: m.notifier,
AuxSweeper: fn.Some[AuxSweeper](&MockAuxSweeper{}),
})
return tp, m
}
// TestCreateAndCheckTx checks `createAndCheckTx` behaves as expected.
func TestCreateAndCheckTx(t *testing.T) {
t.Parallel()
// Create a test request.
inp := createTestInput(1000, input.WitnessKeyHash)
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test feerate and return it from the mock fee function.
feerate := chainfee.SatPerKWeight(1000)
m.feeFunc.On("FeeRate").Return(feerate)
// Mock the wallet to fail on testmempoolaccept on the first call, and
// succeed on the second.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(errDummy).Once()
m.wallet.On("CheckMempoolAcceptance", mock.Anything).Return(nil).Once()
// Mock the signer to always return a valid script.
//
// NOTE: we are not testing the utility of creating valid txes here, so
// this is fine to be mocked. This behaves essentially as skipping the
// Signer check and alaways assume the tx has a valid sig.
script := &input.Script{}
m.signer.On("ComputeInputScript", mock.Anything,
mock.Anything).Return(script, nil)
testCases := []struct {
name string
req *BumpRequest
expectedErr error
}{
{
// When the budget cannot cover the fee, an error
// should be returned.
name: "not enough budget",
req: &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
},
expectedErr: ErrNotEnoughBudget,
},
{
// When the mempool rejects the transaction, an error
// should be returned.
name: "testmempoolaccept fail",
req: &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: btcutil.Amount(1000),
},
expectedErr: errDummy,
},
{
// When the mempool accepts the transaction, no error
// should be returned.
name: "testmempoolaccept pass",
req: &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: btcutil.Amount(1000),
},
expectedErr: nil,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Call the method under test.
_, err := tp.createAndCheckTx(tc.req, m.feeFunc)
// Check the result is as expected.
require.ErrorIs(t, err, tc.expectedErr)
})
}
}
// createTestBumpRequest creates a new bump request.
func createTestBumpRequest() *BumpRequest {
// Create a test input.
inp := createTestInput(1000, input.WitnessKeyHash)
return &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: btcutil.Amount(1000),
}
}
// TestCreateRBFCompliantTx checks that `createRBFCompliantTx` behaves as
// expected.
func TestCreateRBFCompliantTx(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test bump request.
req := createTestBumpRequest()
// Create a test feerate and return it from the mock fee function.
feerate := chainfee.SatPerKWeight(1000)
m.feeFunc.On("FeeRate").Return(feerate)
// Mock the signer to always return a valid script.
//
// NOTE: we are not testing the utility of creating valid txes here, so
// this is fine to be mocked. This behaves essentially as skipping the
// Signer check and alaways assume the tx has a valid sig.
script := &input.Script{}
m.signer.On("ComputeInputScript", mock.Anything,
mock.Anything).Return(script, nil)
testCases := []struct {
name string
setupMock func()
expectedErr error
}{
{
// When testmempoolaccept accepts the tx, no error
// should be returned.
name: "success case",
setupMock: func() {
// Mock the testmempoolaccept to pass.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(nil).Once()
},
expectedErr: nil,
},
{
// When testmempoolaccept fails due to a non-fee
// related error, an error should be returned.
name: "non-fee related testmempoolaccept fail",
setupMock: func() {
// Mock the testmempoolaccept to fail.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(errDummy).Once()
},
expectedErr: errDummy,
},
{
// When increase feerate gives an error, the error
// should be returned.
name: "fail on increase fee",
setupMock: func() {
// Mock the testmempoolaccept to fail on fee.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(
lnwallet.ErrMempoolFee).Once()
// Mock the fee function to return an error.
m.feeFunc.On("Increment").Return(
false, errDummy).Once()
},
expectedErr: errDummy,
},
{
// Test that after one round of increasing the feerate
// the tx passes testmempoolaccept.
name: "increase fee and success on min mempool fee",
setupMock: func() {
// Mock the testmempoolaccept to fail on fee
// for the first call.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(
lnwallet.ErrMempoolFee).Once()
// Mock the fee function to increase feerate.
m.feeFunc.On("Increment").Return(
true, nil).Once()
// Mock the testmempoolaccept to pass on the
// second call.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(nil).Once()
},
expectedErr: nil,
},
{
// Test that after one round of increasing the feerate
// the tx passes testmempoolaccept.
name: "increase fee and success on insufficient fee",
setupMock: func() {
// Mock the testmempoolaccept to fail on fee
// for the first call.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(
chain.ErrInsufficientFee).Once()
// Mock the fee function to increase feerate.
m.feeFunc.On("Increment").Return(
true, nil).Once()
// Mock the testmempoolaccept to pass on the
// second call.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(nil).Once()
},
expectedErr: nil,
},
{
// Test that the fee function increases the fee rate
// after one round.
name: "increase fee on second round",
setupMock: func() {
// Mock the testmempoolaccept to fail on fee
// for the first call.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(
chain.ErrInsufficientFee).Once()
// Mock the fee function to NOT increase
// feerate on the first round.
m.feeFunc.On("Increment").Return(
false, nil).Once()
// Mock the fee function to increase feerate.
m.feeFunc.On("Increment").Return(
true, nil).Once()
// Mock the testmempoolaccept to pass on the
// second call.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(nil).Once()
},
expectedErr: nil,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
tc.setupMock()
// Call the method under test.
id, err := tp.createRBFCompliantTx(req, m.feeFunc)
// Check the result is as expected.
require.ErrorIs(t, err, tc.expectedErr)
// If there's an error, expect the requestID to be
// empty.
if tc.expectedErr != nil {
require.Zero(t, id)
}
})
}
}
// TestTxPublisherBroadcast checks the internal `broadcast` method behaves as
// expected.
func TestTxPublisherBroadcast(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test bump request.
req := createTestBumpRequest()
// Create a test tx.
tx := &wire.MsgTx{LockTime: 1}
// Create a test feerate and return it from the mock fee function.
feerate := chainfee.SatPerKWeight(1000)
m.feeFunc.On("FeeRate").Return(feerate)
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
requestID := tp.storeRecord(tx, req, m.feeFunc, fee)
// Quickly check when the requestID cannot be found, an error is
// returned.
result, err := tp.broadcast(uint64(1000))
require.Error(t, err)
require.Nil(t, result)
testCases := []struct {
name string
setupMock func()
expectedErr error
expectedResult *BumpResult
}{
{
// When the wallet cannot publish this tx, the error
// should be put inside the result.
name: "fail to publish",
setupMock: func() {
// Mock the wallet to fail to publish.
m.wallet.On("PublishTransaction",
tx, mock.Anything).Return(
errDummy).Once()
},
expectedErr: nil,
expectedResult: &BumpResult{
Event: TxFailed,
Tx: tx,
Fee: fee,
FeeRate: feerate,
Err: errDummy,
requestID: requestID,
},
},
{
// When nothing goes wrong, the result is returned.
name: "publish success",
setupMock: func() {
// Mock the wallet to publish successfully.
m.wallet.On("PublishTransaction",
tx, mock.Anything).Return(nil).Once()
},
expectedErr: nil,
expectedResult: &BumpResult{
Event: TxPublished,
Tx: tx,
Fee: fee,
FeeRate: feerate,
Err: nil,
requestID: requestID,
},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
tc.setupMock()
// Call the method under test.
result, err := tp.broadcast(requestID)
// Check the result is as expected.
require.ErrorIs(t, err, tc.expectedErr)
require.Equal(t, tc.expectedResult, result)
})
}
}
// TestRemoveResult checks the records and subscriptions are removed when a tx
// is confirmed or failed.
func TestRemoveResult(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test bump request.
req := createTestBumpRequest()
// Create a test tx.
tx := &wire.MsgTx{LockTime: 1}
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
testCases := []struct {
name string
setupRecord func() uint64
result *BumpResult
removed bool
}{
{
// When the tx is confirmed, the records will be
// removed.
name: "remove on TxConfirmed",
setupRecord: func() uint64 {
id := tp.storeRecord(tx, req, m.feeFunc, fee)
tp.subscriberChans.Store(id, nil)
return id
},
result: &BumpResult{
Event: TxConfirmed,
Tx: tx,
},
removed: true,
},
{
// When the tx is failed, the records will be removed.
name: "remove on TxFailed",
setupRecord: func() uint64 {
id := tp.storeRecord(tx, req, m.feeFunc, fee)
tp.subscriberChans.Store(id, nil)
return id
},
result: &BumpResult{
Event: TxFailed,
Err: errDummy,
Tx: tx,
},
removed: true,
},
{
// Noop when the tx is neither confirmed or failed.
name: "noop when tx is not confirmed or failed",
setupRecord: func() uint64 {
id := tp.storeRecord(tx, req, m.feeFunc, fee)
tp.subscriberChans.Store(id, nil)
return id
},
result: &BumpResult{
Event: TxPublished,
Tx: tx,
},
removed: false,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
requestID := tc.setupRecord()
// Attach the requestID from the setup.
tc.result.requestID = requestID
// Remove the result.
tp.removeResult(tc.result)
// Check if the record is removed.
_, found := tp.records.Load(requestID)
require.Equal(t, !tc.removed, found)
_, found = tp.subscriberChans.Load(requestID)
require.Equal(t, !tc.removed, found)
})
}
}
// TestNotifyResult checks the subscribers are notified when a result is sent.
func TestNotifyResult(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test bump request.
req := createTestBumpRequest()
// Create a test tx.
tx := &wire.MsgTx{LockTime: 1}
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
requestID := tp.storeRecord(tx, req, m.feeFunc, fee)
// Create a subscription to the event.
subscriber := make(chan *BumpResult, 1)
tp.subscriberChans.Store(requestID, subscriber)
// Create a test result.
result := &BumpResult{
requestID: requestID,
Tx: tx,
}
// Notify the result and expect the subscriber to receive it.
//
// NOTE: must be done inside a goroutine in case it blocks.
go tp.notifyResult(result)
select {
case <-time.After(time.Second):
t.Fatal("timeout waiting for subscriber to receive result")
case received := <-subscriber:
require.Equal(t, result, received)
}
// Notify two results. This time it should block because the channel is
// full. We then shutdown TxPublisher to test the quit behavior.
done := make(chan struct{})
go func() {
// Call notifyResult twice, which blocks at the second call.
tp.notifyResult(result)
tp.notifyResult(result)
close(done)
}()
// Shutdown the publisher and expect notifyResult to exit.
close(tp.quit)
// We expect to done chan.
select {
case <-time.After(time.Second):
t.Fatal("timeout waiting for notifyResult to exit")
case <-done:
}
}
// TestBroadcastSuccess checks the public `Broadcast` method can successfully
// broadcast a tx based on the request.
func TestBroadcastSuccess(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test feerate.
feerate := chainfee.SatPerKWeight(1000)
// Mock the fee estimator to return the testing fee rate.
//
// We are not testing `NewLinearFeeFunction` here, so the actual params
// used are irrelevant.
m.estimator.On("EstimateFeePerKW", mock.Anything).Return(
feerate, nil).Once()
m.estimator.On("RelayFeePerKW").Return(chainfee.FeePerKwFloor).Once()
// Mock the signer to always return a valid script.
//
// NOTE: we are not testing the utility of creating valid txes here, so
// this is fine to be mocked. This behaves essentially as skipping the
// Signer check and alaways assume the tx has a valid sig.
script := &input.Script{}
m.signer.On("ComputeInputScript", mock.Anything,
mock.Anything).Return(script, nil)
// Mock the testmempoolaccept to pass.
m.wallet.On("CheckMempoolAcceptance", mock.Anything).Return(nil).Once()
// Mock the wallet to publish successfully.
m.wallet.On("PublishTransaction",
mock.Anything, mock.Anything).Return(nil).Once()
// Create a test request.
inp := createTestInput(1000, input.WitnessKeyHash)
// Create a testing bump request.
req := &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: btcutil.Amount(1000),
MaxFeeRate: feerate * 10,
DeadlineHeight: 10,
}
// Send the req and expect no error.
resultChan, err := tp.Broadcast(req)
require.NoError(t, err)
// Check the result is sent back.
select {
case <-time.After(time.Second):
t.Fatal("timeout waiting for subscriber to receive result")
case result := <-resultChan:
// We expect the first result to be TxPublished.
require.Equal(t, TxPublished, result.Event)
}
// Validate the record was stored.
require.Equal(t, 1, tp.records.Len())
require.Equal(t, 1, tp.subscriberChans.Len())
}
// TestBroadcastFail checks the public `Broadcast` returns the error or a
// failed result when the broadcast fails.
func TestBroadcastFail(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test feerate.
feerate := chainfee.SatPerKWeight(1000)
// Create a test request.
inp := createTestInput(1000, input.WitnessKeyHash)
// Create a testing bump request.
req := &BumpRequest{
DeliveryAddress: changePkScript,
Inputs: []input.Input{&inp},
Budget: btcutil.Amount(1000),
MaxFeeRate: feerate * 10,
DeadlineHeight: 10,
}
// Mock the fee estimator to return the testing fee rate.
//
// We are not testing `NewLinearFeeFunction` here, so the actual params
// used are irrelevant.
m.estimator.On("EstimateFeePerKW", mock.Anything).Return(
feerate, nil).Twice()
m.estimator.On("RelayFeePerKW").Return(chainfee.FeePerKwFloor).Twice()
// Mock the signer to always return a valid script.
//
// NOTE: we are not testing the utility of creating valid txes here, so
// this is fine to be mocked. This behaves essentially as skipping the
// Signer check and alaways assume the tx has a valid sig.
script := &input.Script{}
m.signer.On("ComputeInputScript", mock.Anything,
mock.Anything).Return(script, nil)
// Mock the testmempoolaccept to return an error.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(errDummy).Once()
// Send the req and expect an error returned.
resultChan, err := tp.Broadcast(req)
require.ErrorIs(t, err, errDummy)
require.Nil(t, resultChan)
// Validate the record was NOT stored.
require.Equal(t, 0, tp.records.Len())
require.Equal(t, 0, tp.subscriberChans.Len())
// Mock the testmempoolaccept again, this time it passes.
m.wallet.On("CheckMempoolAcceptance", mock.Anything).Return(nil).Once()
// Mock the wallet to fail on publish.
m.wallet.On("PublishTransaction",
mock.Anything, mock.Anything).Return(errDummy).Once()
// Send the req and expect no error returned.
resultChan, err = tp.Broadcast(req)
require.NoError(t, err)
// Check the result is sent back.
select {
case <-time.After(time.Second):
t.Fatal("timeout waiting for subscriber to receive result")
case result := <-resultChan:
// We expect the result to be TxFailed and the error is set in
// the result.
require.Equal(t, TxFailed, result.Event)
require.ErrorIs(t, result.Err, errDummy)
}
// Validate the record was removed.
require.Equal(t, 0, tp.records.Len())
require.Equal(t, 0, tp.subscriberChans.Len())
}
// TestCreateAnPublishFail checks all the error cases are handled properly in
// the method createAndPublish.
func TestCreateAnPublishFail(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test requestID.
requestID := uint64(1)
// Create a test feerate and return it from the mock fee function.
feerate := chainfee.SatPerKWeight(1000)
m.feeFunc.On("FeeRate").Return(feerate)
// Create a testing monitor record.
req := createTestBumpRequest()
// Overwrite the budget to make it smaller than the fee.
req.Budget = 100
record := &monitorRecord{
req: req,
feeFunction: m.feeFunc,
tx: &wire.MsgTx{},
}
// Mock the signer to always return a valid script.
//
// NOTE: we are not testing the utility of creating valid txes here, so
// this is fine to be mocked. This behaves essentially as skipping the
// Signer check and alaways assume the tx has a valid sig.
script := &input.Script{}
m.signer.On("ComputeInputScript", mock.Anything,
mock.Anything).Return(script, nil)
// Call the createAndPublish method.
resultOpt := tp.createAndPublishTx(requestID, record)
result := resultOpt.UnwrapOrFail(t)
// We expect the result to be TxFailed and the error is set in the
// result.
require.Equal(t, TxFailed, result.Event)
require.ErrorIs(t, result.Err, ErrNotEnoughBudget)
require.Equal(t, requestID, result.requestID)
// Increase the budget and call it again. This time we will mock an
// error to be returned from CheckMempoolAcceptance.
req.Budget = 1000
// Mock the testmempoolaccept to return a fee related error that should
// be ignored.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(lnwallet.ErrMempoolFee).Once()
// Call the createAndPublish method and expect a none option.
resultOpt = tp.createAndPublishTx(requestID, record)
require.True(t, resultOpt.IsNone())
// Mock the testmempoolaccept to return a fee related error that should
// be ignored.
m.wallet.On("CheckMempoolAcceptance",
mock.Anything).Return(chain.ErrInsufficientFee).Once()
// Call the createAndPublish method and expect a none option.
resultOpt = tp.createAndPublishTx(requestID, record)
require.True(t, resultOpt.IsNone())
}
// TestCreateAnPublishSuccess checks the expected result is returned from the
// method createAndPublish.
func TestCreateAnPublishSuccess(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test requestID.
requestID := uint64(1)
// Create a test feerate and return it from the mock fee function.
feerate := chainfee.SatPerKWeight(1000)
m.feeFunc.On("FeeRate").Return(feerate)
// Create a testing monitor record.
req := createTestBumpRequest()
record := &monitorRecord{
req: req,
feeFunction: m.feeFunc,
tx: &wire.MsgTx{},
}
// Mock the signer to always return a valid script.
//
// NOTE: we are not testing the utility of creating valid txes here, so
// this is fine to be mocked. This behaves essentially as skipping the
// Signer check and alaways assume the tx has a valid sig.
script := &input.Script{}
m.signer.On("ComputeInputScript", mock.Anything,
mock.Anything).Return(script, nil)
// Mock the testmempoolaccept to return nil.
m.wallet.On("CheckMempoolAcceptance", mock.Anything).Return(nil)
// Mock the wallet to publish and return an error.
m.wallet.On("PublishTransaction",
mock.Anything, mock.Anything).Return(errDummy).Once()
// Call the createAndPublish method and expect a failure result.
resultOpt := tp.createAndPublishTx(requestID, record)
result := resultOpt.UnwrapOrFail(t)
// We expect the result to be TxFailed and the error is set.
require.Equal(t, TxFailed, result.Event)
require.ErrorIs(t, result.Err, errDummy)
// Although the replacement tx was failed to be published, the record
// should be stored.
require.NotNil(t, result.Tx)
require.NotNil(t, result.ReplacedTx)
_, found := tp.records.Load(requestID)
require.True(t, found)
// We now check a successful RBF.
//
// Mock the wallet to publish successfully.
m.wallet.On("PublishTransaction",
mock.Anything, mock.Anything).Return(nil).Once()
// Call the createAndPublish method and expect a success result.
resultOpt = tp.createAndPublishTx(requestID, record)
result = resultOpt.UnwrapOrFail(t)
require.True(t, resultOpt.IsSome())
// We expect the result to be TxReplaced and the error is nil.
require.Equal(t, TxReplaced, result.Event)
require.Nil(t, result.Err)
// Check the Tx and ReplacedTx are set.
require.NotNil(t, result.Tx)
require.NotNil(t, result.ReplacedTx)
// Check the record is stored.
_, found = tp.records.Load(requestID)
require.True(t, found)
}
// TestHandleTxConfirmed checks the expected result is returned from the method
// handleTxConfirmed.
func TestHandleTxConfirmed(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test bump request.
req := createTestBumpRequest()
// Create a test tx.
tx := &wire.MsgTx{LockTime: 1}
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
requestID := tp.storeRecord(tx, req, m.feeFunc, fee)
record, ok := tp.records.Load(requestID)
require.True(t, ok)
// Create a subscription to the event.
subscriber := make(chan *BumpResult, 1)
tp.subscriberChans.Store(requestID, subscriber)
// Mock the fee function to return a fee rate.
feerate := chainfee.SatPerKWeight(1000)
m.feeFunc.On("FeeRate").Return(feerate).Once()
// Call the method and expect a result to be received.
//
// NOTE: must be called in a goroutine in case it blocks.
tp.wg.Add(1)
done := make(chan struct{})
go func() {
tp.handleTxConfirmed(record, requestID)
close(done)
}()
select {
case <-time.After(time.Second):
t.Fatal("timeout waiting for subscriber to receive result")
case result := <-subscriber:
// We expect the result to be TxConfirmed and the tx is set.
require.Equal(t, TxConfirmed, result.Event)
require.Equal(t, tx, result.Tx)
require.Nil(t, result.Err)
require.Equal(t, requestID, result.requestID)
require.Equal(t, record.fee, result.Fee)
require.Equal(t, feerate, result.FeeRate)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timeout waiting for handleTxConfirmed to return")
}
// We expect the record to be removed from the maps.
_, found := tp.records.Load(requestID)
require.False(t, found)
_, found = tp.subscriberChans.Load(requestID)
require.False(t, found)
}
// TestHandleFeeBumpTx validates handleFeeBumpTx behaves as expected.
func TestHandleFeeBumpTx(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create a test tx.
tx := &wire.MsgTx{LockTime: 1}
// Create a test current height.
testHeight := int32(800000)
// Create a testing monitor record.
req := createTestBumpRequest()
record := &monitorRecord{
req: req,
feeFunction: m.feeFunc,
tx: tx,
}
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
requestID := tp.storeRecord(tx, req, m.feeFunc, fee)
// Create a subscription to the event.
subscriber := make(chan *BumpResult, 1)
tp.subscriberChans.Store(requestID, subscriber)
// Create a test feerate and return it from the mock fee function.
feerate := chainfee.SatPerKWeight(1000)
m.feeFunc.On("FeeRate").Return(feerate)
// Mock the fee function to skip the bump due to error.
m.feeFunc.On("IncreaseFeeRate", mock.Anything).Return(
false, errDummy).Once()
// Call the method and expect no result received.
tp.wg.Add(1)
go tp.handleFeeBumpTx(requestID, record, testHeight)
// Check there's no result sent back.
select {
case <-time.After(time.Second):
case result := <-subscriber:
t.Fatalf("unexpected result received: %v", result)
}
// Mock the fee function to skip the bump.
m.feeFunc.On("IncreaseFeeRate", mock.Anything).Return(false, nil).Once()
// Call the method and expect no result received.
tp.wg.Add(1)
go tp.handleFeeBumpTx(requestID, record, testHeight)
// Check there's no result sent back.
select {
case <-time.After(time.Second):
case result := <-subscriber:
t.Fatalf("unexpected result received: %v", result)
}
// Mock the fee function to perform the fee bump.
m.feeFunc.On("IncreaseFeeRate", mock.Anything).Return(true, nil)
// Mock the signer to always return a valid script.
//
// NOTE: we are not testing the utility of creating valid txes here, so
// this is fine to be mocked. This behaves essentially as skipping the
// Signer check and alaways assume the tx has a valid sig.
script := &input.Script{}
m.signer.On("ComputeInputScript", mock.Anything,
mock.Anything).Return(script, nil)
// Mock the testmempoolaccept to return nil.
m.wallet.On("CheckMempoolAcceptance", mock.Anything).Return(nil)
// Mock the wallet to publish successfully.
m.wallet.On("PublishTransaction",
mock.Anything, mock.Anything).Return(nil).Once()
// Call the method and expect a result to be received.
//
// NOTE: must be called in a goroutine in case it blocks.
tp.wg.Add(1)
go tp.handleFeeBumpTx(requestID, record, testHeight)
select {
case <-time.After(time.Second):
t.Fatal("timeout waiting for subscriber to receive result")
case result := <-subscriber:
// We expect the result to be TxReplaced.
require.Equal(t, TxReplaced, result.Event)
// The new tx and old tx should be properly set.
require.NotEqual(t, tx, result.Tx)
require.Equal(t, tx, result.ReplacedTx)
// No error should be set.
require.Nil(t, result.Err)
require.Equal(t, requestID, result.requestID)
}
// We expect the record to NOT be removed from the maps.
_, found := tp.records.Load(requestID)
require.True(t, found)
_, found = tp.subscriberChans.Load(requestID)
require.True(t, found)
}
// TestProcessRecords validates processRecords behaves as expected.
func TestProcessRecords(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
tp, m := createTestPublisher(t)
// Create testing objects.
requestID1 := uint64(1)
req1 := createTestBumpRequest()
tx1 := &wire.MsgTx{LockTime: 1}
txid1 := tx1.TxHash()
requestID2 := uint64(2)
req2 := createTestBumpRequest()
tx2 := &wire.MsgTx{LockTime: 2}
txid2 := tx2.TxHash()
// Create a monitor record that's confirmed.
recordConfirmed := &monitorRecord{
req: req1,
feeFunction: m.feeFunc,
tx: tx1,
}
m.wallet.On("GetTransactionDetails", &txid1).Return(
&lnwallet.TransactionDetail{
NumConfirmations: 1,
}, nil,
).Once()
// Create a monitor record that's not confirmed. We know it's not
// confirmed because the num of confirms is zero.
recordFeeBump := &monitorRecord{
req: req2,
feeFunction: m.feeFunc,
tx: tx2,
}
m.wallet.On("GetTransactionDetails", &txid2).Return(
&lnwallet.TransactionDetail{
NumConfirmations: 0,
}, nil,
).Once()
m.wallet.On("BackEnd").Return("test-backend").Once()
// Setup the initial publisher state by adding the records to the maps.
subscriberConfirmed := make(chan *BumpResult, 1)
tp.subscriberChans.Store(requestID1, subscriberConfirmed)
tp.records.Store(requestID1, recordConfirmed)
subscriberReplaced := make(chan *BumpResult, 1)
tp.subscriberChans.Store(requestID2, subscriberReplaced)
tp.records.Store(requestID2, recordFeeBump)
// Create a test feerate and return it from the mock fee function.
feerate := chainfee.SatPerKWeight(1000)
m.feeFunc.On("FeeRate").Return(feerate)
// The following methods should only be called once when creating the
// replacement tx.
//
// Mock the fee function to NOT skip the fee bump.
m.feeFunc.On("IncreaseFeeRate", mock.Anything).Return(true, nil).Once()
// Mock the signer to always return a valid script.
m.signer.On("ComputeInputScript", mock.Anything,
mock.Anything).Return(&input.Script{}, nil).Once()
// Mock the testmempoolaccept to return nil.
m.wallet.On("CheckMempoolAcceptance", mock.Anything).Return(nil).Once()
// Mock the wallet to publish successfully.
m.wallet.On("PublishTransaction",
mock.Anything, mock.Anything).Return(nil).Once()
// Call processRecords and expect the results are notified back.
tp.processRecords()
// We expect two results to be received. One for the confirmed tx and
// one for the replaced tx.
//
// Check the confirmed tx result.
select {
case <-time.After(time.Second):
t.Fatal("timeout waiting for subscriberConfirmed")
case result := <-subscriberConfirmed:
// We expect the result to be TxConfirmed.
require.Equal(t, TxConfirmed, result.Event)
require.Equal(t, tx1, result.Tx)
// No error should be set.
require.Nil(t, result.Err)
require.Equal(t, requestID1, result.requestID)
}
// Now check the replaced tx result.
select {
case <-time.After(time.Second):
t.Fatal("timeout waiting for subscriberReplaced")
case result := <-subscriberReplaced:
// We expect the result to be TxReplaced.
require.Equal(t, TxReplaced, result.Event)
// The new tx and old tx should be properly set.
require.NotEqual(t, tx2, result.Tx)
require.Equal(t, tx2, result.ReplacedTx)
// No error should be set.
require.Nil(t, result.Err)
require.Equal(t, requestID2, result.requestID)
}
}