package sweep import ( "fmt" "sync/atomic" "testing" "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/chainntnfs" "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 = []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) 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) 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: []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, }) // 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{}) // 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, }) 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( rpcclient.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( rpcclient.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(rpcclient.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) } }